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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user