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