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
|
||||
discador tem originate próprio
|
||||
- [ ] `max_recording_storage_gb` (Plan) existe mas não é aplicado
|
||||
- [ ] Transcrição (secao 95+, fase IA)
|
||||
- [ ] Transcrição (secao 95+, fase IA) — implementado parcialmente na
|
||||
PHASE 19 (só o Provider Layer; pipeline/transcrição real na PHASE 20)
|
||||
|
||||
## PHASE 19+ — ver `agente.md` seções 95 em diante (AI, Billing, Frontend,
|
||||
Security, Tests)
|
||||
## PHASE 19 — IA: Provider Layer (agente.md secao 95-103)
|
||||
- [x] Schema completo do módulo de IA numa única migration (todas as
|
||||
tabelas das secoes 95-124: providers/models/prompts/jobs/
|
||||
transcriptions/analyses/scorecards/usage) — código só pro Provider
|
||||
Layer nesta fase, resto vem em fases separadas
|
||||
- [x] `packages/ai`: interface `AIProvider` (secao 96, nunca hardcoda
|
||||
OpenAI no domínio), `OpenAIProvider`/`AnthropicProvider` (secao
|
||||
97-98, nomenclatura "OpenAI API"/"Anthropic API" nunca "ChatGPT"),
|
||||
`SensitiveDataRedactor` (secao 123, CPF/CNPJ/telefone/email/cartão)
|
||||
- [x] **Nunca exercitados contra rede real**: adapters OpenAI/Anthropic —
|
||||
esta sessão só tem autorização de rede pro servidor git (restrição
|
||||
desde o primeiro pedido do usuário)
|
||||
- [x] `ai_providers`/`ai_models`: global (platform admin, visível de
|
||||
qualquer tenant) vs. BYOK (secao 100-101) — RLS híbrida (`tenant_id
|
||||
= current OR tenant_id IS NULL`, mesma técnica de
|
||||
`tenant_memberships` no login), escrita em GLOBAL exige
|
||||
`isPlatformUser` na camada de serviço, key nunca reexposta
|
||||
(`apiKeyPreview`)
|
||||
- [x] **Bug real, achado no teste desta fase**: delete de provider fazia
|
||||
hard delete, bloqueado por FK quando um `AIModel` (mesmo
|
||||
soft-deleted) ainda referenciava — inconsistente com o resto do
|
||||
sistema (tudo soft delete). Corrigido; `GET /ai/providers` também
|
||||
não filtrava desabilitados, corrigido junto.
|
||||
- [x] **Bug real, achado no teste do redactor**: `\b` antes de `\(?`
|
||||
opcional falha quando o char anterior também não é de palavra
|
||||
(espaço + `(`) — vazava um parêntese solto. Corrigido com `(?<!\w)`.
|
||||
- [x] Testado ponta a ponta com 2 tenants + platform admin: GLOBAL só
|
||||
platform admin cria/apaga, BYOK isolado por RLS (tenant B nunca vê
|
||||
BYOK do tenant A, 404 em id direto), modelo de provider GLOBAL
|
||||
visível dos dois tenants, redactor com 5 tipos de dado sensível
|
||||
- [ ] Pipeline assíncrono pós-CALL_ENDED, transcrição/análise/prompts/
|
||||
scorecards/usage metering reais — PHASE 20
|
||||
|
||||
## PHASE 20+ — ver `agente.md` seções 104 em diante (Transcrição, Análise,
|
||||
Scorecards, Usage Metering, Billing, Frontend, Security, Tests)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@b2bcall/ai": "workspace:*",
|
||||
"@b2bcall/auth": "workspace:*",
|
||||
"@b2bcall/database": "workspace:*",
|
||||
"@b2bcall/entitlements": "workspace:*",
|
||||
|
||||
83
apps/api/src/ai/ai-models.controller.ts
Normal file
83
apps/api/src/ai/ai-models.controller.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { CreateAIModelDto } from "./dto/create-ai-model.dto";
|
||||
|
||||
/** "ai_models" (agente.md secao 102-103) — modelos configuráveis por
|
||||
* provider, cada um com seu conjunto de capabilities e custos. */
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("ai/models")
|
||||
export class AIModelsController {
|
||||
@RequirePermission("ai.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAIModelDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const provider = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIProvider.findFirst({ where: { id: dto.providerId } }),
|
||||
);
|
||||
if (!provider) throw new NotFoundException("Provider nao encontrado");
|
||||
|
||||
const model = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIModel.create({
|
||||
data: {
|
||||
tenantId: provider.tenantId,
|
||||
providerId: provider.id,
|
||||
externalModelId: dto.externalModelId,
|
||||
displayName: dto.displayName,
|
||||
capabilities: (dto.capabilities ?? []) as Prisma.AIModelCreateInput["capabilities"],
|
||||
inputCost: dto.inputCost,
|
||||
outputCost: dto.outputCost,
|
||||
audioCost: dto.audioCost,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_MODEL_CREATE",
|
||||
tenantId: provider.tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_model",
|
||||
entityId: model.id,
|
||||
after: { displayName: model.displayName, externalModelId: model.externalModelId },
|
||||
});
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
@RequirePermission("ai.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
return withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIModel.findMany({ where: { enabled: true }, orderBy: { displayName: "asc" } }),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission("ai.manage")
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIModel.updateMany({ where: { id }, data: { enabled: false } }),
|
||||
);
|
||||
if (result.count === 0) throw new NotFoundException();
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_MODEL_DELETE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_model",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
136
apps/api/src/ai/ai-providers.controller.ts
Normal file
136
apps/api/src/ai/ai-providers.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext, type AIProvider } from "@b2bcall/database";
|
||||
import { encryptSecret } from "@b2bcall/shared";
|
||||
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { SUPPORTED_PROVIDER_TYPES } from "@b2bcall/ai";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { CreateAIProviderDto } from "./dto/create-ai-provider.dto";
|
||||
|
||||
// Nunca reexpõe encryptedApiKey (agente.md secao 101: "nunca mostrar key
|
||||
// inteira após salvar") — só um preview dos últimos 4 caracteres, o
|
||||
// suficiente pra reconhecer qual key é sem nunca vazar a key completa de
|
||||
// volta pro client.
|
||||
function serializeProvider(provider: AIProvider) {
|
||||
const { encryptedApiKey: _encryptedApiKey, ...rest } = provider;
|
||||
return { ...rest, apiKeyPreview: `...${provider.encryptedApiKey.slice(-8)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers de IA (agente.md secao 96-101). `scope=GLOBAL` (cadastrado
|
||||
* pelo platform admin, visível de qualquer tenant — secao 100) só pode ser
|
||||
* criado/apagado por quem tem role PLATFORM; `scope=TENANT` (BYOK) é livre
|
||||
* pra qualquer tenant admin gerenciar só o próprio.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("ai/providers")
|
||||
export class AIProvidersController {
|
||||
@RequirePermission("ai.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAIProviderDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
if (dto.scope === "GLOBAL") {
|
||||
const isPlatform = await isPlatformUser(user.sub);
|
||||
if (!isPlatform) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode cadastrar um provider GLOBAL");
|
||||
}
|
||||
}
|
||||
if (!SUPPORTED_PROVIDER_TYPES.includes(dto.providerType)) {
|
||||
throw new ForbiddenException(
|
||||
`providerType nao suportado: ${dto.providerType} (suportados: ${SUPPORTED_PROVIDER_TYPES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
const provider = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIProvider.create({
|
||||
data: {
|
||||
scope: dto.scope,
|
||||
tenantId: dto.scope === "GLOBAL" ? null : tenantId,
|
||||
providerType: dto.providerType,
|
||||
name: dto.name,
|
||||
baseUrl: dto.baseUrl,
|
||||
encryptedApiKey: encryptSecret(dto.apiKey),
|
||||
organization: dto.organization,
|
||||
project: dto.project,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_PROVIDER_CREATE",
|
||||
tenantId: dto.scope === "GLOBAL" ? null : tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_provider",
|
||||
entityId: provider.id,
|
||||
after: { providerType: provider.providerType, name: provider.name, scope: provider.scope },
|
||||
});
|
||||
|
||||
return serializeProvider(provider);
|
||||
}
|
||||
|
||||
/** Global (visível de qualquer tenant) + BYOK do próprio tenant — a
|
||||
* própria RLS híbrida já resolve essa união, sem WHERE extra aqui. */
|
||||
@RequirePermission("ai.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const providers = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIProvider.findMany({ where: { enabled: true }, orderBy: { name: "asc" } }),
|
||||
);
|
||||
return providers.map(serializeProvider);
|
||||
}
|
||||
|
||||
@RequirePermission("ai.manage")
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const provider = await withTenantContext(prisma, tenantId, (tx) => tx.aIProvider.findFirst({ where: { id } }));
|
||||
if (!provider) throw new NotFoundException();
|
||||
|
||||
if (provider.scope === "GLOBAL") {
|
||||
const isPlatform = await isPlatformUser(user.sub);
|
||||
if (!isPlatform) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode apagar um provider GLOBAL");
|
||||
}
|
||||
} else if (provider.tenantId !== tenantId) {
|
||||
// Nunca deveria acontecer (RLS ja' filtra), mas nunca custa checar
|
||||
// explicitamente antes de uma escrita (secao 31/146).
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Soft delete (mesmo padrão de Disposition/PauseReason/etc.) — nunca
|
||||
// hard delete: um AIModel ainda pode referenciar este provider (FK
|
||||
// RESTRICT), e o histórico de CallTranscription/CallAIAnalysis que já
|
||||
// usou este provider precisa continuar apontando pra um provider_id
|
||||
// válido mesmo depois de desativado.
|
||||
await withTenantContext(prisma, tenantId, (tx) => tx.aIProvider.update({ where: { id }, data: { enabled: false } }));
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_PROVIDER_DELETE",
|
||||
tenantId: provider.scope === "GLOBAL" ? null : tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_provider",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
apps/api/src/ai/ai.module.ts
Normal file
8
apps/api/src/ai/ai.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AIProvidersController } from "./ai-providers.controller";
|
||||
import { AIModelsController } from "./ai-models.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [AIProvidersController, AIModelsController],
|
||||
})
|
||||
export class AIModule {}
|
||||
33
apps/api/src/ai/dto/create-ai-model.dto.ts
Normal file
33
apps/api/src/ai/dto/create-ai-model.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||
|
||||
const CAPABILITIES = ["TRANSCRIPTION", "DIARIZATION", "TEXT_ANALYSIS", "STRUCTURED_OUTPUT", "EMBEDDINGS", "REALTIME_AUDIO"];
|
||||
|
||||
export class CreateAIModelDto {
|
||||
@IsUUID()
|
||||
providerId!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
externalModelId!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
displayName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(CAPABILITIES, { each: true })
|
||||
capabilities?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
inputCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
outputCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
audioCost?: number;
|
||||
}
|
||||
34
apps/api/src/ai/dto/create-ai-provider.dto.ts
Normal file
34
apps/api/src/ai/dto/create-ai-provider.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class CreateAIProviderDto {
|
||||
@IsIn(["GLOBAL", "TENANT"])
|
||||
scope!: "GLOBAL" | "TENANT";
|
||||
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
providerType!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
baseUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(500)
|
||||
apiKey!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
organization?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
project?: string;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { DispositionsModule } from "./dispositions/dispositions.module";
|
||||
import { CallsModule } from "./calls/calls.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { RecordingsModule } from "./recordings/recordings.module";
|
||||
import { AIModule } from "./ai/ai.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { RecordingsModule } from "./recordings/recordings.module";
|
||||
CallsModule,
|
||||
ReportsModule,
|
||||
RecordingsModule,
|
||||
AIModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
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,6 +16,18 @@ enum TenantStatus {
|
||||
@@map("tenant_status")
|
||||
}
|
||||
|
||||
// Privacidade de IA (agente.md secao 122) — resolvida em cascata
|
||||
// Campaign > Queue > Tenant (o mais específico que não for null vence).
|
||||
// Tenant é o único nível obrigatório (default OFF: preciso de opt-in
|
||||
// explícito antes de mandar áudio de cliente pra um provider externo).
|
||||
enum AIPrivacyLevel {
|
||||
AI_OFF
|
||||
TRANSCRIPTION_ONLY
|
||||
TRANSCRIPTION_AND_ANALYSIS
|
||||
|
||||
@@map("ai_privacy_level")
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
code String @unique
|
||||
@@ -29,6 +41,7 @@ model Tenant {
|
||||
billingCurrency String @default("BRL") @map("billing_currency")
|
||||
telephonyDomain String? @map("telephony_domain")
|
||||
planId String @map("plan_id") @db.Uuid
|
||||
aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
@@ -57,6 +70,18 @@ model Tenant {
|
||||
callLegs CallLeg[]
|
||||
callEvents CallEvent[]
|
||||
recordings Recording[]
|
||||
aiproviders AIProvider[]
|
||||
aimodels AIModel[]
|
||||
aipromptTemplates AIPromptTemplate[]
|
||||
aijobs AIJob[]
|
||||
callTranscriptions CallTranscription[]
|
||||
callAIAnalyses CallAIAnalysis[]
|
||||
qualityScorecards QualityScorecard[]
|
||||
qualityEvaluations QualityEvaluation[]
|
||||
aiusageRecords AIUsageRecord[]
|
||||
aipromptVersions AIPromptVersion[]
|
||||
callTranscriptSegments CallTranscriptSegment[]
|
||||
qualityScorecardItems QualityScorecardItem[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -487,6 +512,9 @@ model Queue {
|
||||
|
||||
recordingEnabled Boolean @default(false) @map("recording_enabled")
|
||||
|
||||
// null = herda do Tenant (secao 122).
|
||||
aiPrivacyLevel AIPrivacyLevel? @map("ai_privacy_level")
|
||||
|
||||
enabled Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -721,6 +749,10 @@ model Campaign {
|
||||
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
|
||||
aiAnalysisEnabled Boolean @default(false) @map("ai_analysis_enabled")
|
||||
|
||||
// Secao 116: sobrescreve o template padrão do tenant pra ANALYSIS
|
||||
// (null = usa o template ANALYSIS padrão resolvido normalmente).
|
||||
analysisPromptTemplateId String? @map("analysis_prompt_template_id") @db.Uuid
|
||||
|
||||
status CampaignStatus @default(DRAFT)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -730,6 +762,7 @@ model Campaign {
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
queue Queue @relation(fields: [queueId], references: [id])
|
||||
trunk Trunk @relation(fields: [trunkId], references: [id])
|
||||
analysisPromptTemplate AIPromptTemplate? @relation(fields: [analysisPromptTemplateId], references: [id])
|
||||
leads Lead[]
|
||||
callAttempts CallAttempt[]
|
||||
campaignStats CampaignStats[]
|
||||
@@ -1013,6 +1046,11 @@ model Call {
|
||||
legs CallLeg[]
|
||||
events CallEvent[]
|
||||
recording Recording?
|
||||
aijobs AIJob[]
|
||||
callTranscriptions CallTranscription[]
|
||||
callAIAnalyses CallAIAnalysis[]
|
||||
qualityEvaluations QualityEvaluation[]
|
||||
aiusageRecords AIUsageRecord[]
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([tenantId, queueId])
|
||||
@@ -1118,3 +1156,419 @@ model Recording {
|
||||
@@index([retentionUntil])
|
||||
@@map("recordings")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Módulo IA (agente.md secao 95-124)
|
||||
// ============================================================
|
||||
|
||||
enum AIProviderScope {
|
||||
GLOBAL
|
||||
TENANT
|
||||
|
||||
@@map("ai_provider_scope")
|
||||
}
|
||||
|
||||
// "ai_providers" (secao 96-101). `providerType` é String, não enum — a
|
||||
// especificação exige explicitamente "não hardcode OpenAI no domínio" e
|
||||
// "arquitetura deve permitir Google/Azure/Bedrock/modelos locais/outros
|
||||
// futuramente" (secao 96-97); um enum Postgres exigiria uma migration
|
||||
// pra cada provider novo, o oposto do espírito da abstração. A validação
|
||||
// de quais `providerType` têm adapter implementado de verdade acontece na
|
||||
// camada de serviço (packages/ai), não no banco.
|
||||
//
|
||||
// RLS híbrida: `scope=GLOBAL` (tenantId null, cadastrado pelo platform
|
||||
// admin) é visível de QUALQUER contexto de tenant — é a mesma técnica já
|
||||
// usada pra `tenant_memberships` na descoberta de tenant durante login
|
||||
// (USING com OR). `scope=TENANT` (BYOK, secao 100) só é visível no
|
||||
// próprio contexto do tenant dono. Quem pode ESCREVER num provider GLOBAL
|
||||
// é decidido na camada de serviço (só platform admin), não pela RLS.
|
||||
model AIProvider {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
scope AIProviderScope
|
||||
tenantId String? @map("tenant_id") @db.Uuid
|
||||
|
||||
providerType String @map("provider_type")
|
||||
name String
|
||||
|
||||
baseUrl String? @map("base_url")
|
||||
|
||||
// AES-256-GCM (secao 101, mesmo padrão de sipPasswordEnc/passwordEnc) —
|
||||
// a key inteira nunca é reexibida depois de salva.
|
||||
encryptedApiKey String @map("encrypted_api_key")
|
||||
|
||||
organization String?
|
||||
project String?
|
||||
|
||||
enabled Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
models AIModel[]
|
||||
callTranscriptions CallTranscription[]
|
||||
callAIAnalyses CallAIAnalysis[]
|
||||
aiusageRecords AIUsageRecord[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("ai_providers")
|
||||
}
|
||||
|
||||
enum AICapability {
|
||||
TRANSCRIPTION
|
||||
DIARIZATION
|
||||
TEXT_ANALYSIS
|
||||
STRUCTURED_OUTPUT
|
||||
EMBEDDINGS
|
||||
REALTIME_AUDIO
|
||||
|
||||
@@map("ai_capability")
|
||||
}
|
||||
|
||||
// "ai_models" (secao 102-103) — mesma RLS híbrida do provider dono
|
||||
// (tenantId copiado do AIProvider na criação, evita subquery de RLS
|
||||
// cruzando tabelas).
|
||||
model AIModel {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String? @map("tenant_id") @db.Uuid
|
||||
providerId String @map("provider_id") @db.Uuid
|
||||
|
||||
externalModelId String @map("external_model_id")
|
||||
displayName String @map("display_name")
|
||||
|
||||
capabilities AICapability[]
|
||||
|
||||
inputCost Float? @map("input_cost")
|
||||
outputCost Float? @map("output_cost")
|
||||
audioCost Float? @map("audio_cost")
|
||||
|
||||
enabled Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
provider AIProvider @relation(fields: [providerId], references: [id])
|
||||
|
||||
@@unique([providerId, externalModelId])
|
||||
@@index([tenantId])
|
||||
@@map("ai_models")
|
||||
}
|
||||
|
||||
enum AIPromptPurpose {
|
||||
ANALYSIS
|
||||
SCORECARD
|
||||
|
||||
@@map("ai_prompt_purpose")
|
||||
}
|
||||
|
||||
// "ai_prompt_templates"/"ai_prompt_versions" (secao 114-116). `tenantId
|
||||
// null` = template padrão da plataforma; Campaign pode sobrescrever
|
||||
// apontando pro seu próprio template via `analysisPromptTemplateId`
|
||||
// (secao 116) — o template em si não carrega campaignId, evita um
|
||||
// template "pertencer" a uma campanha específica de forma rígida (a
|
||||
// mesma campanha de cobrança de vários tenants pode reusar o mesmo
|
||||
// template). Versões nunca são editadas in-place — cada mudança de
|
||||
// conteúdo cria uma nova `AIPromptVersion`, a versão ativa é apontada por
|
||||
// `activeVersionId` (histórico completo preservado, mesmo espírito de
|
||||
// "immutable usage ledger" da secao 233 aplicado a prompts).
|
||||
model AIPromptTemplate {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String? @map("tenant_id") @db.Uuid
|
||||
|
||||
purpose AIPromptPurpose
|
||||
name String
|
||||
|
||||
activeVersionId String? @unique @map("active_version_id") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
activeVersion AIPromptVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id])
|
||||
versions AIPromptVersion[] @relation("TemplateVersions")
|
||||
campaigns Campaign[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("ai_prompt_templates")
|
||||
}
|
||||
|
||||
model AIPromptVersion {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String? @map("tenant_id") @db.Uuid
|
||||
templateId String @map("template_id") @db.Uuid
|
||||
|
||||
version Int
|
||||
content String
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
template AIPromptTemplate @relation("TemplateVersions", fields: [templateId], references: [id])
|
||||
activeFor AIPromptTemplate? @relation("ActiveVersion")
|
||||
|
||||
@@unique([templateId, version])
|
||||
@@index([tenantId])
|
||||
@@map("ai_prompt_versions")
|
||||
}
|
||||
|
||||
enum AIJobType {
|
||||
TRANSCRIPTION
|
||||
ANALYSIS
|
||||
REANALYSIS
|
||||
|
||||
@@map("ai_job_type")
|
||||
}
|
||||
|
||||
enum AIJobStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
RETRYING
|
||||
CANCELLED
|
||||
|
||||
@@map("ai_job_status")
|
||||
}
|
||||
|
||||
// "ai_jobs" (secao 106-108) — pipeline sempre assíncrono, nunca bloqueia
|
||||
// a chamada esperando IA. `scheduledAt` é quando o job pode rodar de novo
|
||||
// (exponential backoff no retry); `attemptCount >= maxAttempts` vira
|
||||
// FAILED terminal (dead-letter, secao 108: nunca retry infinito).
|
||||
model AIJob {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
callId String @map("call_id") @db.Uuid
|
||||
|
||||
type AIJobType
|
||||
status AIJobStatus @default(PENDING)
|
||||
|
||||
attemptCount Int @default(0) @map("attempt_count")
|
||||
maxAttempts Int @default(5) @map("max_attempts")
|
||||
|
||||
lastError String? @map("last_error")
|
||||
scheduledAt DateTime @default(now()) @map("scheduled_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
call Call @relation(fields: [callId], references: [id])
|
||||
|
||||
@@index([tenantId, status, scheduledAt])
|
||||
@@index([tenantId, callId])
|
||||
@@map("ai_jobs")
|
||||
}
|
||||
|
||||
enum TranscriptionStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
FAILED
|
||||
|
||||
@@map("transcription_status")
|
||||
}
|
||||
|
||||
// "call_transcriptions" (secao 109).
|
||||
model CallTranscription {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
callId String @map("call_id") @db.Uuid
|
||||
|
||||
providerId String? @map("provider_id") @db.Uuid
|
||||
model String?
|
||||
|
||||
language String?
|
||||
text String?
|
||||
|
||||
status TranscriptionStatus @default(PENDING)
|
||||
|
||||
durationSeconds Int? @map("duration_seconds")
|
||||
|
||||
providerRequestId String? @map("provider_request_id")
|
||||
|
||||
inputUsage Int? @map("input_usage")
|
||||
outputUsage Int? @map("output_usage")
|
||||
|
||||
providerCost Float? @map("provider_cost")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
call Call @relation(fields: [callId], references: [id])
|
||||
provider AIProvider? @relation(fields: [providerId], references: [id])
|
||||
segments CallTranscriptSegment[]
|
||||
|
||||
@@index([tenantId, callId])
|
||||
@@map("call_transcriptions")
|
||||
}
|
||||
|
||||
enum TranscriptSpeaker {
|
||||
AGENT
|
||||
CUSTOMER
|
||||
UNKNOWN
|
||||
|
||||
@@map("transcript_speaker")
|
||||
}
|
||||
|
||||
// "call_transcript_segments" (secao 110-111) — `speaker` prioriza o canal
|
||||
// estéreo (secao 111: "não confiar cegamente em diarização quando a
|
||||
// direção do áudio permite identificação melhor") sobre a diarização
|
||||
// probabilística do provider, quando a gravação for estéreo.
|
||||
model CallTranscriptSegment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
transcriptionId String @map("transcription_id") @db.Uuid
|
||||
|
||||
speaker TranscriptSpeaker @default(UNKNOWN)
|
||||
|
||||
startMs Int @map("start_ms")
|
||||
endMs Int @map("end_ms")
|
||||
|
||||
text String
|
||||
confidence Float?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
transcription CallTranscription @relation(fields: [transcriptionId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([transcriptionId])
|
||||
@@map("call_transcript_segments")
|
||||
}
|
||||
|
||||
// "call_ai_analyses" (secao 112-113) — resultado estruturado, validado
|
||||
// contra um schema antes de persistir (secao 113: "não usar somente texto
|
||||
// livre"), nunca o texto cru da resposta do modelo.
|
||||
model CallAIAnalysis {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
callId String @map("call_id") @db.Uuid
|
||||
|
||||
providerId String? @map("provider_id") @db.Uuid
|
||||
model String?
|
||||
|
||||
summary String?
|
||||
customerIntent String? @map("customer_intent")
|
||||
outcome String?
|
||||
sentiment String?
|
||||
|
||||
topics String[]
|
||||
keywords String[]
|
||||
objections String[]
|
||||
questions String[]
|
||||
actionItems String[] @map("action_items")
|
||||
|
||||
complianceFlags String[] @map("compliance_flags")
|
||||
riskFlags String[] @map("risk_flags")
|
||||
|
||||
qualityScore Int? @map("quality_score")
|
||||
agentScore Int? @map("agent_score")
|
||||
customerSentimentScore Float? @map("customer_sentiment_score")
|
||||
|
||||
salesOpportunity Boolean? @map("sales_opportunity")
|
||||
nextBestAction String? @map("next_best_action")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
call Call @relation(fields: [callId], references: [id])
|
||||
provider AIProvider? @relation(fields: [providerId], references: [id])
|
||||
|
||||
@@index([tenantId, callId])
|
||||
@@map("call_ai_analyses")
|
||||
}
|
||||
|
||||
// "quality_scorecards"/"quality_scorecard_items"/"quality_evaluations"
|
||||
// (secao 117-118).
|
||||
model QualityScorecard {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
|
||||
name String
|
||||
enabled Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
items QualityScorecardItem[]
|
||||
evaluations QualityEvaluation[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("quality_scorecards")
|
||||
}
|
||||
|
||||
model QualityScorecardItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
scorecardId String @map("scorecard_id") @db.Uuid
|
||||
|
||||
name String
|
||||
weight Float @default(1)
|
||||
description String?
|
||||
evaluationPrompt String? @map("evaluation_prompt")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
scorecard QualityScorecard @relation(fields: [scorecardId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([scorecardId])
|
||||
@@map("quality_scorecard_items")
|
||||
}
|
||||
|
||||
// Secao 118: guarda score final + scores por critério + justificativa —
|
||||
// nunca o chain-of-thought do modelo.
|
||||
model QualityEvaluation {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
callId String @map("call_id") @db.Uuid
|
||||
scorecardId String @map("scorecard_id") @db.Uuid
|
||||
|
||||
score Int
|
||||
criterionScores Json @map("criterion_scores")
|
||||
summaryJustification String? @map("summary_justification")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
call Call @relation(fields: [callId], references: [id])
|
||||
scorecard QualityScorecard @relation(fields: [scorecardId], references: [id])
|
||||
|
||||
@@index([tenantId, callId])
|
||||
@@map("quality_evaluations")
|
||||
}
|
||||
|
||||
enum AIUsageType {
|
||||
AI_TRANSCRIPTION_SECONDS
|
||||
AI_ANALYSIS_REQUEST
|
||||
AI_INPUT_TOKENS
|
||||
AI_OUTPUT_TOKENS
|
||||
|
||||
@@map("ai_usage_type")
|
||||
}
|
||||
|
||||
// "AI usage metering" (secao 124) — ledger imutável (secao 233: "immutable
|
||||
// usage ledger > reconstruir billing de forma improvisada"), só INSERT
|
||||
// pelo código da aplicação, nunca UPDATE/DELETE. Alimenta a fase Billing
|
||||
// (Rating Engine), ainda não construída.
|
||||
model AIUsageRecord {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
callId String? @map("call_id") @db.Uuid
|
||||
|
||||
type AIUsageType
|
||||
quantity Float
|
||||
|
||||
providerId String? @map("provider_id") @db.Uuid
|
||||
model String?
|
||||
|
||||
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
call Call? @relation(fields: [callId], references: [id])
|
||||
provider AIProvider? @relation(fields: [providerId], references: [id])
|
||||
|
||||
@@index([tenantId, occurredAt])
|
||||
@@index([tenantId, type])
|
||||
@@map("ai_usage_records")
|
||||
}
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@b2bcall/ai':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ai
|
||||
'@b2bcall/auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/auth
|
||||
@@ -181,6 +184,15 @@ importers:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/ai:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.20.1
|
||||
version: 22.20.1
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/auth:
|
||||
dependencies:
|
||||
'@b2bcall/database':
|
||||
|
||||
Reference in New Issue
Block a user