feat(ai): provider layer — abstracao, OpenAI/Anthropic, global+BYOK

Fecha agente.md secao 95-103. Primeira peca do modulo de IA — a
abstracao de provider, os adapters OpenAI/Anthropic, capabilities, e o
cadastro de providers/modelos (global + BYOK). O pipeline que aciona
isso depois de uma chamada terminar (transcricao, analise, jobs
assincronos, prompts, scorecards, usage metering — secao 104-124) fica
pra proxima fase.

## Schema completo do modulo de IA numa unica migration

Todas as tabelas das secoes 95-124 de uma vez (ai_providers/ai_models/
ai_prompt_templates+versions/ai_jobs/call_transcriptions+segments/
call_ai_analyses/quality_scorecards+items+evaluations/ai_usage_records) —
mais barato revisar o desenho relacional inteiro numa unica passada do
que fatiar em migrations pequenas que se emendam. O codigo que usa essas
tabelas vem em fases separadas, so' Provider/Model nesta.

## packages/ai

Interface AIProvider (secao 96: nunca hardcoda OpenAI no dominio).
OpenAIProvider/AnthropicProvider (secao 97-98, nomenclatura "OpenAI API"/
"Anthropic API", nunca "ChatGPT") via fetch nativo direto contra cada API
— sem SDK oficial, request/response inteiramente visivel no proprio
codigo (relevante ja' que manda dado de cliente pra fora, secao 122-123).
transcribe so' na OpenAI (Anthropic nao tem endpoint de audio, secao 102:
"nem todo provider tem todas as capacidades"); analyze/structuredGenerate
via Structured Outputs na OpenAI e "tool use" forcado na Anthropic.
SensitiveDataRedactor (secao 123): CPF/CNPJ/telefone/email/cartao.

**Nunca exercitados contra rede real** — esta sessao so' tem autorizacao
de rede pro servidor git (restricao definida desde o primeiro pedido do
usuario). Mesmo padrao de honestidade ja' usado pro S3ObjectStorageProvider
e o caminho PSTN real.

## ai_providers/ai_models — global vs. BYOK

scope=GLOBAL (platform admin, tenant_id null) visivel de qualquer tenant;
scope=TENANT (BYOK) so' do dono. RLS hibrida (tenant_id = current OR
tenant_id IS NULL, mesma tecnica de tenant_memberships no login); quem
pode ESCREVER num GLOBAL e' decidido na camada de servico
(isPlatformUser), nao pela RLS. Key nunca reexposta (so' apiKeyPreview).

## Dois bugs reais achados testando esta fase

- Delete de provider fazia hard delete, bloqueado por FK quando um
  AIModel (mesmo soft-deleted) ainda referenciava — inconsistente com o
  resto do sistema (tudo soft delete). Corrigido; GET /ai/providers
  tambem nao filtrava desabilitados, corrigido junto.
- SensitiveDataRedactor: \b antes de \(? opcional falha quando o char
  anterior tambem nao e' de palavra (espaco + "("), vazando um parenteses
  solto (nenhum dado sensivel de verdade vazava). Corrigido com (?<!\w).

Verificado ponta a ponta com 2 tenants + platform admin: GLOBAL so'
platform admin cria/apaga, BYOK isolado por RLS (tenant B nunca ve' BYOK
do tenant A, 404 em id direto), modelo de provider GLOBAL visivel dos
dois tenants, redactor com 5 tipos de dado sensivel todos corretos.

typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 15:05:00 -03:00
parent c24a86776c
commit 7597b35454
20 changed files with 1878 additions and 62 deletions

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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