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

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")
}