- Replace old DDL structure with new numbered files (01-24) - Update migrations and seeds for new schema - Clean up deprecated files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
853 lines
29 KiB
PL/PgSQL
853 lines
29 KiB
PL/PgSQL
-- =============================================================
|
|
-- ARCHIVO: 14-ai.sql
|
|
-- DESCRIPCION: Sistema de AI/ML, prompts, completions, embeddings
|
|
-- VERSION: 1.0.0
|
|
-- PROYECTO: ERP-Core V2
|
|
-- FECHA: 2026-01-10
|
|
-- EPIC: SAAS-AI (EPIC-SAAS-007)
|
|
-- HISTORIAS: US-080, US-081, US-082
|
|
-- =============================================================
|
|
|
|
-- =====================
|
|
-- SCHEMA: ai
|
|
-- =====================
|
|
CREATE SCHEMA IF NOT EXISTS ai;
|
|
|
|
-- =====================
|
|
-- EXTENSIÓN: pgvector para embeddings
|
|
-- =====================
|
|
-- CREATE EXTENSION IF NOT EXISTS vector;
|
|
|
|
-- =====================
|
|
-- TABLA: ai.models
|
|
-- Modelos de AI disponibles
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.models (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Identificación
|
|
code VARCHAR(100) NOT NULL UNIQUE,
|
|
name VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Proveedor
|
|
provider VARCHAR(50) NOT NULL, -- openai, anthropic, google, azure, local
|
|
model_id VARCHAR(100) NOT NULL, -- gpt-4, claude-3, etc.
|
|
|
|
-- Tipo
|
|
model_type VARCHAR(30) NOT NULL, -- chat, completion, embedding, image, audio
|
|
|
|
-- Capacidades
|
|
max_tokens INTEGER,
|
|
supports_functions BOOLEAN DEFAULT FALSE,
|
|
supports_vision BOOLEAN DEFAULT FALSE,
|
|
supports_streaming BOOLEAN DEFAULT TRUE,
|
|
|
|
-- Costos (por 1K tokens)
|
|
input_cost_per_1k DECIMAL(10,6),
|
|
output_cost_per_1k DECIMAL(10,6),
|
|
|
|
-- Límites
|
|
rate_limit_rpm INTEGER, -- Requests per minute
|
|
rate_limit_tpm INTEGER, -- Tokens per minute
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
is_default BOOLEAN DEFAULT FALSE,
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.prompts
|
|
-- Biblioteca de prompts del sistema
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.prompts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
|
|
|
|
-- Identificación
|
|
code VARCHAR(100) NOT NULL,
|
|
name VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
category VARCHAR(50), -- assistant, analysis, generation, extraction
|
|
|
|
-- Contenido
|
|
system_prompt TEXT,
|
|
user_prompt_template TEXT NOT NULL,
|
|
-- Variables: {{variable_name}}
|
|
|
|
-- Configuración del modelo
|
|
model_id UUID REFERENCES ai.models(id),
|
|
temperature DECIMAL(3,2) DEFAULT 0.7,
|
|
max_tokens INTEGER,
|
|
top_p DECIMAL(3,2),
|
|
frequency_penalty DECIMAL(3,2),
|
|
presence_penalty DECIMAL(3,2),
|
|
|
|
-- Variables requeridas
|
|
required_variables TEXT[] DEFAULT '{}',
|
|
variable_schema JSONB DEFAULT '{}',
|
|
|
|
-- Funciones (para function calling)
|
|
functions JSONB DEFAULT '[]',
|
|
|
|
-- Versionamiento
|
|
version INTEGER DEFAULT 1,
|
|
is_latest BOOLEAN DEFAULT TRUE,
|
|
parent_version_id UUID REFERENCES ai.prompts(id),
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
is_system BOOLEAN DEFAULT FALSE,
|
|
|
|
-- Estadísticas
|
|
usage_count INTEGER DEFAULT 0,
|
|
avg_tokens_used INTEGER,
|
|
avg_latency_ms INTEGER,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
|
|
UNIQUE(tenant_id, code, version)
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.conversations
|
|
-- Conversaciones con el asistente AI
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.conversations (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Identificación
|
|
title VARCHAR(255),
|
|
summary TEXT,
|
|
|
|
-- Contexto
|
|
context_type VARCHAR(50), -- general, sales, inventory, support
|
|
context_data JSONB DEFAULT '{}',
|
|
|
|
-- Modelo usado
|
|
model_id UUID REFERENCES ai.models(id),
|
|
prompt_id UUID REFERENCES ai.prompts(id),
|
|
|
|
-- Estado
|
|
status VARCHAR(20) DEFAULT 'active', -- active, archived, deleted
|
|
is_pinned BOOLEAN DEFAULT FALSE,
|
|
|
|
-- Estadísticas
|
|
message_count INTEGER DEFAULT 0,
|
|
total_tokens INTEGER DEFAULT 0,
|
|
total_cost DECIMAL(10,4) DEFAULT 0,
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
tags TEXT[] DEFAULT '{}',
|
|
|
|
-- Tiempos
|
|
last_message_at TIMESTAMPTZ,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.messages
|
|
-- Mensajes en conversaciones
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.messages (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Mensaje
|
|
role VARCHAR(20) NOT NULL, -- system, user, assistant, function
|
|
content TEXT NOT NULL,
|
|
|
|
-- Función (si aplica)
|
|
function_name VARCHAR(100),
|
|
function_arguments JSONB,
|
|
function_result JSONB,
|
|
|
|
-- Modelo usado
|
|
model_id UUID REFERENCES ai.models(id),
|
|
model_response_id VARCHAR(255), -- ID de respuesta del proveedor
|
|
|
|
-- Tokens y costos
|
|
prompt_tokens INTEGER,
|
|
completion_tokens INTEGER,
|
|
total_tokens INTEGER,
|
|
cost DECIMAL(10,6),
|
|
|
|
-- Performance
|
|
latency_ms INTEGER,
|
|
finish_reason VARCHAR(30), -- stop, length, function_call, content_filter
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
-- Feedback
|
|
feedback_rating INTEGER, -- 1-5
|
|
feedback_text TEXT,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.completions
|
|
-- Completaciones individuales (no conversacionales)
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.completions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
user_id UUID REFERENCES auth.users(id),
|
|
|
|
-- Prompt usado
|
|
prompt_id UUID REFERENCES ai.prompts(id),
|
|
prompt_code VARCHAR(100),
|
|
|
|
-- Modelo
|
|
model_id UUID REFERENCES ai.models(id),
|
|
|
|
-- Input/Output
|
|
input_text TEXT NOT NULL,
|
|
input_variables JSONB DEFAULT '{}',
|
|
output_text TEXT,
|
|
|
|
-- Tokens y costos
|
|
prompt_tokens INTEGER,
|
|
completion_tokens INTEGER,
|
|
total_tokens INTEGER,
|
|
cost DECIMAL(10,6),
|
|
|
|
-- Performance
|
|
latency_ms INTEGER,
|
|
finish_reason VARCHAR(30),
|
|
|
|
-- Estado
|
|
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
|
|
error_message TEXT,
|
|
|
|
-- Contexto
|
|
context_type VARCHAR(50),
|
|
context_id UUID,
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.embeddings
|
|
-- Embeddings vectoriales
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.embeddings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Contenido original
|
|
content TEXT NOT NULL,
|
|
content_hash VARCHAR(64), -- SHA-256 para deduplicación
|
|
|
|
-- Vector
|
|
-- embedding vector(1536), -- Para OpenAI ada-002
|
|
embedding_json JSONB, -- Alternativa si no hay pgvector
|
|
|
|
-- Modelo usado
|
|
model_id UUID REFERENCES ai.models(id),
|
|
model_name VARCHAR(100),
|
|
dimensions INTEGER,
|
|
|
|
-- Asociación
|
|
entity_type VARCHAR(100), -- product, document, faq
|
|
entity_id UUID,
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
tags TEXT[] DEFAULT '{}',
|
|
|
|
-- Chunks (si es parte de un documento grande)
|
|
chunk_index INTEGER,
|
|
chunk_total INTEGER,
|
|
parent_embedding_id UUID REFERENCES ai.embeddings(id),
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.usage_logs
|
|
-- Log de uso de AI para billing
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.usage_logs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
user_id UUID REFERENCES auth.users(id),
|
|
|
|
-- Modelo
|
|
model_id UUID REFERENCES ai.models(id),
|
|
model_name VARCHAR(100),
|
|
provider VARCHAR(50),
|
|
|
|
-- Tipo de uso
|
|
usage_type VARCHAR(30) NOT NULL, -- chat, completion, embedding, image
|
|
|
|
-- Tokens
|
|
prompt_tokens INTEGER DEFAULT 0,
|
|
completion_tokens INTEGER DEFAULT 0,
|
|
total_tokens INTEGER DEFAULT 0,
|
|
|
|
-- Costos
|
|
cost DECIMAL(10,6) DEFAULT 0,
|
|
|
|
-- Contexto
|
|
conversation_id UUID,
|
|
completion_id UUID,
|
|
request_id VARCHAR(255),
|
|
|
|
-- Periodo
|
|
usage_date DATE DEFAULT CURRENT_DATE,
|
|
usage_month VARCHAR(7), -- 2026-01
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.tenant_quotas
|
|
-- Cuotas de AI por tenant
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.tenant_quotas (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Límites mensuales
|
|
monthly_token_limit INTEGER,
|
|
monthly_request_limit INTEGER,
|
|
monthly_cost_limit DECIMAL(10,2),
|
|
|
|
-- Uso actual del mes
|
|
current_tokens INTEGER DEFAULT 0,
|
|
current_requests INTEGER DEFAULT 0,
|
|
current_cost DECIMAL(10,4) DEFAULT 0,
|
|
|
|
-- Periodo
|
|
quota_month VARCHAR(7) NOT NULL, -- 2026-01
|
|
|
|
-- Estado
|
|
is_exceeded BOOLEAN DEFAULT FALSE,
|
|
exceeded_at TIMESTAMPTZ,
|
|
|
|
-- Alertas
|
|
alert_threshold_percent INTEGER DEFAULT 80,
|
|
alert_sent_at TIMESTAMPTZ,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(tenant_id, quota_month)
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: ai.knowledge_base
|
|
-- Base de conocimiento para RAG
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS ai.knowledge_base (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
|
|
|
|
-- Identificación
|
|
code VARCHAR(100) NOT NULL,
|
|
name VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Fuente
|
|
source_type VARCHAR(30), -- manual, document, website, api
|
|
source_url TEXT,
|
|
source_file_id UUID,
|
|
|
|
-- Contenido
|
|
content TEXT NOT NULL,
|
|
content_type VARCHAR(50), -- faq, documentation, policy, procedure
|
|
|
|
-- Categorización
|
|
category VARCHAR(100),
|
|
subcategory VARCHAR(100),
|
|
tags TEXT[] DEFAULT '{}',
|
|
|
|
-- Embedding
|
|
embedding_id UUID REFERENCES ai.embeddings(id),
|
|
|
|
-- Relevancia
|
|
priority INTEGER DEFAULT 0,
|
|
relevance_score DECIMAL(5,4),
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
is_verified BOOLEAN DEFAULT FALSE,
|
|
verified_by UUID REFERENCES auth.users(id),
|
|
verified_at TIMESTAMPTZ,
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
|
|
UNIQUE(tenant_id, code)
|
|
);
|
|
|
|
-- =====================
|
|
-- INDICES
|
|
-- =====================
|
|
|
|
-- Indices para models
|
|
CREATE INDEX IF NOT EXISTS idx_models_provider ON ai.models(provider);
|
|
CREATE INDEX IF NOT EXISTS idx_models_type ON ai.models(model_type);
|
|
CREATE INDEX IF NOT EXISTS idx_models_active ON ai.models(is_active) WHERE is_active = TRUE;
|
|
|
|
-- Indices para prompts
|
|
CREATE INDEX IF NOT EXISTS idx_prompts_tenant ON ai.prompts(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_prompts_code ON ai.prompts(code);
|
|
CREATE INDEX IF NOT EXISTS idx_prompts_category ON ai.prompts(category);
|
|
CREATE INDEX IF NOT EXISTS idx_prompts_active ON ai.prompts(is_active) WHERE is_active = TRUE;
|
|
|
|
-- Indices para conversations
|
|
CREATE INDEX IF NOT EXISTS idx_conversations_tenant ON ai.conversations(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_conversations_user ON ai.conversations(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_conversations_status ON ai.conversations(status);
|
|
CREATE INDEX IF NOT EXISTS idx_conversations_created ON ai.conversations(created_at DESC);
|
|
|
|
-- Indices para messages
|
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON ai.messages(conversation_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_tenant ON ai.messages(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_created ON ai.messages(created_at);
|
|
|
|
-- Indices para completions
|
|
CREATE INDEX IF NOT EXISTS idx_completions_tenant ON ai.completions(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_completions_user ON ai.completions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_completions_prompt ON ai.completions(prompt_id);
|
|
CREATE INDEX IF NOT EXISTS idx_completions_context ON ai.completions(context_type, context_id);
|
|
CREATE INDEX IF NOT EXISTS idx_completions_created ON ai.completions(created_at DESC);
|
|
|
|
-- Indices para embeddings
|
|
CREATE INDEX IF NOT EXISTS idx_embeddings_tenant ON ai.embeddings(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_embeddings_entity ON ai.embeddings(entity_type, entity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_embeddings_hash ON ai.embeddings(content_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_embeddings_tags ON ai.embeddings USING GIN(tags);
|
|
|
|
-- Indices para usage_logs
|
|
CREATE INDEX IF NOT EXISTS idx_usage_tenant ON ai.usage_logs(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_usage_date ON ai.usage_logs(usage_date);
|
|
CREATE INDEX IF NOT EXISTS idx_usage_month ON ai.usage_logs(usage_month);
|
|
CREATE INDEX IF NOT EXISTS idx_usage_model ON ai.usage_logs(model_id);
|
|
|
|
-- Indices para tenant_quotas
|
|
CREATE INDEX IF NOT EXISTS idx_quotas_tenant ON ai.tenant_quotas(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_quotas_month ON ai.tenant_quotas(quota_month);
|
|
|
|
-- Indices para knowledge_base
|
|
CREATE INDEX IF NOT EXISTS idx_kb_tenant ON ai.knowledge_base(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kb_category ON ai.knowledge_base(category);
|
|
CREATE INDEX IF NOT EXISTS idx_kb_tags ON ai.knowledge_base USING GIN(tags);
|
|
CREATE INDEX IF NOT EXISTS idx_kb_active ON ai.knowledge_base(is_active) WHERE is_active = TRUE;
|
|
|
|
-- =====================
|
|
-- RLS POLICIES
|
|
-- =====================
|
|
|
|
-- Models son globales
|
|
ALTER TABLE ai.models ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY public_read_models ON ai.models
|
|
FOR SELECT USING (is_active = TRUE);
|
|
|
|
-- Prompts: globales o por tenant
|
|
ALTER TABLE ai.prompts ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_or_global_prompts ON ai.prompts
|
|
FOR SELECT USING (
|
|
tenant_id IS NULL
|
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
|
);
|
|
|
|
-- Conversations por tenant
|
|
ALTER TABLE ai.conversations ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_conversations ON ai.conversations
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Messages por tenant
|
|
ALTER TABLE ai.messages ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_messages ON ai.messages
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Completions por tenant
|
|
ALTER TABLE ai.completions ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_completions ON ai.completions
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Embeddings por tenant
|
|
ALTER TABLE ai.embeddings ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_embeddings ON ai.embeddings
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Usage logs por tenant
|
|
ALTER TABLE ai.usage_logs ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_usage ON ai.usage_logs
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Quotas por tenant
|
|
ALTER TABLE ai.tenant_quotas ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_quotas ON ai.tenant_quotas
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Knowledge base: global o por tenant
|
|
ALTER TABLE ai.knowledge_base ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_or_global_kb ON ai.knowledge_base
|
|
FOR SELECT USING (
|
|
tenant_id IS NULL
|
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
|
);
|
|
|
|
-- =====================
|
|
-- FUNCIONES
|
|
-- =====================
|
|
|
|
-- Función para crear conversación
|
|
CREATE OR REPLACE FUNCTION ai.create_conversation(
|
|
p_tenant_id UUID,
|
|
p_user_id UUID,
|
|
p_title VARCHAR(255) DEFAULT NULL,
|
|
p_context_type VARCHAR(50) DEFAULT 'general',
|
|
p_model_id UUID DEFAULT NULL
|
|
)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_conversation_id UUID;
|
|
BEGIN
|
|
INSERT INTO ai.conversations (
|
|
tenant_id, user_id, title, context_type, model_id
|
|
) VALUES (
|
|
p_tenant_id, p_user_id, p_title, p_context_type, p_model_id
|
|
) RETURNING id INTO v_conversation_id;
|
|
|
|
RETURN v_conversation_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Función para agregar mensaje a conversación
|
|
CREATE OR REPLACE FUNCTION ai.add_message(
|
|
p_conversation_id UUID,
|
|
p_role VARCHAR(20),
|
|
p_content TEXT,
|
|
p_model_id UUID DEFAULT NULL,
|
|
p_tokens JSONB DEFAULT NULL,
|
|
p_latency_ms INTEGER DEFAULT NULL
|
|
)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_message_id UUID;
|
|
v_tenant_id UUID;
|
|
v_prompt_tokens INTEGER;
|
|
v_completion_tokens INTEGER;
|
|
v_total_tokens INTEGER;
|
|
v_cost DECIMAL(10,6);
|
|
BEGIN
|
|
-- Obtener tenant de la conversación
|
|
SELECT tenant_id INTO v_tenant_id
|
|
FROM ai.conversations WHERE id = p_conversation_id;
|
|
|
|
-- Extraer tokens
|
|
v_prompt_tokens := (p_tokens->>'prompt_tokens')::INTEGER;
|
|
v_completion_tokens := (p_tokens->>'completion_tokens')::INTEGER;
|
|
v_total_tokens := COALESCE(v_prompt_tokens, 0) + COALESCE(v_completion_tokens, 0);
|
|
|
|
-- Calcular costo (si hay modelo)
|
|
IF p_model_id IS NOT NULL THEN
|
|
SELECT
|
|
(COALESCE(v_prompt_tokens, 0) * m.input_cost_per_1k / 1000) +
|
|
(COALESCE(v_completion_tokens, 0) * m.output_cost_per_1k / 1000)
|
|
INTO v_cost
|
|
FROM ai.models m WHERE m.id = p_model_id;
|
|
END IF;
|
|
|
|
-- Crear mensaje
|
|
INSERT INTO ai.messages (
|
|
conversation_id, tenant_id, role, content, model_id,
|
|
prompt_tokens, completion_tokens, total_tokens, cost, latency_ms
|
|
) VALUES (
|
|
p_conversation_id, v_tenant_id, p_role, p_content, p_model_id,
|
|
v_prompt_tokens, v_completion_tokens, v_total_tokens, v_cost, p_latency_ms
|
|
) RETURNING id INTO v_message_id;
|
|
|
|
-- Actualizar estadísticas de conversación
|
|
UPDATE ai.conversations
|
|
SET
|
|
message_count = message_count + 1,
|
|
total_tokens = total_tokens + COALESCE(v_total_tokens, 0),
|
|
total_cost = total_cost + COALESCE(v_cost, 0),
|
|
last_message_at = CURRENT_TIMESTAMP,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = p_conversation_id;
|
|
|
|
-- Registrar uso
|
|
IF v_total_tokens > 0 THEN
|
|
PERFORM ai.log_usage(
|
|
v_tenant_id, NULL, p_model_id, 'chat',
|
|
v_prompt_tokens, v_completion_tokens, v_cost
|
|
);
|
|
END IF;
|
|
|
|
RETURN v_message_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Función para registrar uso
|
|
CREATE OR REPLACE FUNCTION ai.log_usage(
|
|
p_tenant_id UUID,
|
|
p_user_id UUID,
|
|
p_model_id UUID,
|
|
p_usage_type VARCHAR(30),
|
|
p_prompt_tokens INTEGER DEFAULT 0,
|
|
p_completion_tokens INTEGER DEFAULT 0,
|
|
p_cost DECIMAL(10,6) DEFAULT 0
|
|
)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_log_id UUID;
|
|
v_model_name VARCHAR(100);
|
|
v_provider VARCHAR(50);
|
|
v_current_month VARCHAR(7);
|
|
BEGIN
|
|
-- Obtener info del modelo
|
|
SELECT name, provider INTO v_model_name, v_provider
|
|
FROM ai.models WHERE id = p_model_id;
|
|
|
|
v_current_month := TO_CHAR(CURRENT_DATE, 'YYYY-MM');
|
|
|
|
-- Registrar uso
|
|
INSERT INTO ai.usage_logs (
|
|
tenant_id, user_id, model_id, model_name, provider,
|
|
usage_type, prompt_tokens, completion_tokens,
|
|
total_tokens, cost, usage_month
|
|
) VALUES (
|
|
p_tenant_id, p_user_id, p_model_id, v_model_name, v_provider,
|
|
p_usage_type, p_prompt_tokens, p_completion_tokens,
|
|
p_prompt_tokens + p_completion_tokens, p_cost, v_current_month
|
|
) RETURNING id INTO v_log_id;
|
|
|
|
-- Actualizar cuota del tenant
|
|
INSERT INTO ai.tenant_quotas (tenant_id, quota_month, current_tokens, current_requests, current_cost)
|
|
VALUES (p_tenant_id, v_current_month, p_prompt_tokens + p_completion_tokens, 1, p_cost)
|
|
ON CONFLICT (tenant_id, quota_month)
|
|
DO UPDATE SET
|
|
current_tokens = ai.tenant_quotas.current_tokens + p_prompt_tokens + p_completion_tokens,
|
|
current_requests = ai.tenant_quotas.current_requests + 1,
|
|
current_cost = ai.tenant_quotas.current_cost + p_cost,
|
|
updated_at = CURRENT_TIMESTAMP;
|
|
|
|
RETURN v_log_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Función para verificar cuota
|
|
CREATE OR REPLACE FUNCTION ai.check_quota(p_tenant_id UUID)
|
|
RETURNS TABLE (
|
|
has_quota BOOLEAN,
|
|
tokens_remaining INTEGER,
|
|
requests_remaining INTEGER,
|
|
cost_remaining DECIMAL,
|
|
percent_used INTEGER
|
|
) AS $$
|
|
DECLARE
|
|
v_quota RECORD;
|
|
BEGIN
|
|
SELECT * INTO v_quota
|
|
FROM ai.tenant_quotas
|
|
WHERE tenant_id = p_tenant_id
|
|
AND quota_month = TO_CHAR(CURRENT_DATE, 'YYYY-MM');
|
|
|
|
IF NOT FOUND THEN
|
|
-- Sin límites configurados
|
|
RETURN QUERY SELECT TRUE, NULL::INTEGER, NULL::INTEGER, NULL::DECIMAL, 0;
|
|
RETURN;
|
|
END IF;
|
|
|
|
RETURN QUERY SELECT
|
|
NOT v_quota.is_exceeded AND
|
|
(v_quota.monthly_token_limit IS NULL OR v_quota.current_tokens < v_quota.monthly_token_limit) AND
|
|
(v_quota.monthly_request_limit IS NULL OR v_quota.current_requests < v_quota.monthly_request_limit) AND
|
|
(v_quota.monthly_cost_limit IS NULL OR v_quota.current_cost < v_quota.monthly_cost_limit),
|
|
|
|
CASE WHEN v_quota.monthly_token_limit IS NOT NULL
|
|
THEN v_quota.monthly_token_limit - v_quota.current_tokens
|
|
ELSE NULL END,
|
|
|
|
CASE WHEN v_quota.monthly_request_limit IS NOT NULL
|
|
THEN v_quota.monthly_request_limit - v_quota.current_requests
|
|
ELSE NULL END,
|
|
|
|
CASE WHEN v_quota.monthly_cost_limit IS NOT NULL
|
|
THEN v_quota.monthly_cost_limit - v_quota.current_cost
|
|
ELSE NULL END,
|
|
|
|
CASE WHEN v_quota.monthly_token_limit IS NOT NULL
|
|
THEN (v_quota.current_tokens * 100 / v_quota.monthly_token_limit)::INTEGER
|
|
ELSE 0 END;
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
-- Función para obtener uso del tenant
|
|
CREATE OR REPLACE FUNCTION ai.get_tenant_usage(
|
|
p_tenant_id UUID,
|
|
p_month VARCHAR(7) DEFAULT NULL
|
|
)
|
|
RETURNS TABLE (
|
|
total_tokens BIGINT,
|
|
total_requests BIGINT,
|
|
total_cost DECIMAL,
|
|
usage_by_model JSONB,
|
|
usage_by_type JSONB,
|
|
daily_usage JSONB
|
|
) AS $$
|
|
DECLARE
|
|
v_month VARCHAR(7);
|
|
BEGIN
|
|
v_month := COALESCE(p_month, TO_CHAR(CURRENT_DATE, 'YYYY-MM'));
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
COALESCE(SUM(ul.total_tokens), 0)::BIGINT as total_tokens,
|
|
COUNT(*)::BIGINT as total_requests,
|
|
COALESCE(SUM(ul.cost), 0)::DECIMAL as total_cost,
|
|
jsonb_object_agg(
|
|
COALESCE(ul.model_name, 'unknown'),
|
|
model_tokens
|
|
) as usage_by_model,
|
|
jsonb_object_agg(ul.usage_type, type_tokens) as usage_by_type,
|
|
jsonb_object_agg(ul.usage_date::TEXT, day_tokens) as daily_usage
|
|
FROM ai.usage_logs ul
|
|
LEFT JOIN (
|
|
SELECT model_name, SUM(total_tokens) as model_tokens
|
|
FROM ai.usage_logs
|
|
WHERE tenant_id = p_tenant_id AND usage_month = v_month
|
|
GROUP BY model_name
|
|
) models ON models.model_name = ul.model_name
|
|
LEFT JOIN (
|
|
SELECT usage_type, SUM(total_tokens) as type_tokens
|
|
FROM ai.usage_logs
|
|
WHERE tenant_id = p_tenant_id AND usage_month = v_month
|
|
GROUP BY usage_type
|
|
) types ON types.usage_type = ul.usage_type
|
|
LEFT JOIN (
|
|
SELECT usage_date, SUM(total_tokens) as day_tokens
|
|
FROM ai.usage_logs
|
|
WHERE tenant_id = p_tenant_id AND usage_month = v_month
|
|
GROUP BY usage_date
|
|
) days ON days.usage_date = ul.usage_date
|
|
WHERE ul.tenant_id = p_tenant_id
|
|
AND ul.usage_month = v_month;
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
-- =====================
|
|
-- TRIGGERS
|
|
-- =====================
|
|
|
|
CREATE OR REPLACE FUNCTION ai.update_timestamp()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER trg_models_updated_at
|
|
BEFORE UPDATE ON ai.models
|
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_prompts_updated_at
|
|
BEFORE UPDATE ON ai.prompts
|
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_conversations_updated_at
|
|
BEFORE UPDATE ON ai.conversations
|
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_embeddings_updated_at
|
|
BEFORE UPDATE ON ai.embeddings
|
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_kb_updated_at
|
|
BEFORE UPDATE ON ai.knowledge_base
|
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
|
|
|
-- =====================
|
|
-- SEED DATA: Modelos
|
|
-- =====================
|
|
INSERT INTO ai.models (code, name, provider, model_id, model_type, max_tokens, supports_functions, supports_vision, input_cost_per_1k, output_cost_per_1k) VALUES
|
|
('gpt-4o', 'GPT-4o', 'openai', 'gpt-4o', 'chat', 128000, TRUE, TRUE, 0.005, 0.015),
|
|
('gpt-4o-mini', 'GPT-4o Mini', 'openai', 'gpt-4o-mini', 'chat', 128000, TRUE, TRUE, 0.00015, 0.0006),
|
|
('gpt-4-turbo', 'GPT-4 Turbo', 'openai', 'gpt-4-turbo', 'chat', 128000, TRUE, TRUE, 0.01, 0.03),
|
|
('claude-3-opus', 'Claude 3 Opus', 'anthropic', 'claude-3-opus-20240229', 'chat', 200000, TRUE, TRUE, 0.015, 0.075),
|
|
('claude-3-sonnet', 'Claude 3 Sonnet', 'anthropic', 'claude-3-sonnet-20240229', 'chat', 200000, TRUE, TRUE, 0.003, 0.015),
|
|
('claude-3-haiku', 'Claude 3 Haiku', 'anthropic', 'claude-3-haiku-20240307', 'chat', 200000, TRUE, TRUE, 0.00025, 0.00125),
|
|
('text-embedding-3-small', 'Text Embedding 3 Small', 'openai', 'text-embedding-3-small', 'embedding', 8191, FALSE, FALSE, 0.00002, 0),
|
|
('text-embedding-3-large', 'Text Embedding 3 Large', 'openai', 'text-embedding-3-large', 'embedding', 8191, FALSE, FALSE, 0.00013, 0)
|
|
ON CONFLICT (code) DO NOTHING;
|
|
|
|
-- =====================
|
|
-- SEED DATA: Prompts del Sistema
|
|
-- =====================
|
|
INSERT INTO ai.prompts (code, name, category, system_prompt, user_prompt_template, required_variables, is_system) VALUES
|
|
('assistant_general', 'Asistente General', 'assistant',
|
|
'Eres un asistente virtual para un sistema ERP. Ayudas a los usuarios con consultas sobre ventas, inventario, facturación y gestión empresarial. Responde de forma clara y concisa en español.',
|
|
'{{user_message}}',
|
|
'{user_message}', TRUE),
|
|
|
|
('sales_analysis', 'Análisis de Ventas', 'analysis',
|
|
'Eres un analista de ventas experto. Analiza los datos proporcionados y genera insights accionables.',
|
|
'Analiza los siguientes datos de ventas:\n\n{{sales_data}}\n\nGenera un resumen ejecutivo con los principales hallazgos.',
|
|
'{sales_data}', TRUE),
|
|
|
|
('product_description', 'Generador de Descripción', 'generation',
|
|
'Eres un copywriter experto en productos. Genera descripciones atractivas y persuasivas.',
|
|
'Genera una descripción de producto para:\n\nNombre: {{product_name}}\nCategoría: {{category}}\nCaracterísticas: {{features}}\n\nLa descripción debe ser de {{word_count}} palabras aproximadamente.',
|
|
'{product_name,category,features,word_count}', TRUE),
|
|
|
|
('invoice_data_extraction', 'Extracción de Facturas', 'extraction',
|
|
'Eres un experto en extracción de datos de documentos fiscales mexicanos. Extrae la información estructurada de facturas.',
|
|
'Extrae los datos de la siguiente factura:\n\n{{invoice_text}}\n\nDevuelve los datos en formato JSON con los campos: rfc_emisor, rfc_receptor, fecha, total, conceptos.',
|
|
'{invoice_text}', TRUE),
|
|
|
|
('support_response', 'Respuesta de Soporte', 'assistant',
|
|
'Eres un agente de soporte técnico. Responde de forma amable y profesional, proporcionando soluciones claras.',
|
|
'El cliente tiene el siguiente problema:\n\n{{issue_description}}\n\nContexto adicional:\n{{context}}\n\nGenera una respuesta de soporte apropiada.',
|
|
'{issue_description,context}', TRUE)
|
|
|
|
ON CONFLICT (tenant_id, code, version) DO NOTHING;
|
|
|
|
-- =====================
|
|
-- COMENTARIOS
|
|
-- =====================
|
|
COMMENT ON TABLE ai.models IS 'Modelos de AI disponibles (OpenAI, Anthropic, etc.)';
|
|
COMMENT ON TABLE ai.prompts IS 'Biblioteca de prompts del sistema y personalizados';
|
|
COMMENT ON TABLE ai.conversations IS 'Conversaciones con el asistente AI';
|
|
COMMENT ON TABLE ai.messages IS 'Mensajes individuales en conversaciones';
|
|
COMMENT ON TABLE ai.completions IS 'Completaciones individuales (no conversacionales)';
|
|
COMMENT ON TABLE ai.embeddings IS 'Embeddings vectoriales para búsqueda semántica';
|
|
COMMENT ON TABLE ai.usage_logs IS 'Log de uso de AI para billing y analytics';
|
|
COMMENT ON TABLE ai.tenant_quotas IS 'Cuotas de uso de AI por tenant';
|
|
COMMENT ON TABLE ai.knowledge_base IS 'Base de conocimiento para RAG';
|
|
|
|
COMMENT ON FUNCTION ai.create_conversation IS 'Crea una nueva conversación con el asistente';
|
|
COMMENT ON FUNCTION ai.add_message IS 'Agrega un mensaje a una conversación';
|
|
COMMENT ON FUNCTION ai.log_usage IS 'Registra uso de AI para billing';
|
|
COMMENT ON FUNCTION ai.check_quota IS 'Verifica si el tenant tiene cuota disponible';
|