[DDL] feat: Sprint 5 - Add llm schema with 5 tables

- conversations: Conversaciones con agente LLM
- conversation_messages: Mensajes individuales
- llm_tools_usage: Registro de uso de herramientas
- llm_proactive_notifications: Notificaciones proactivas
- llm_usage_limits: Limites de uso por usuario/plan VIP

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 20:13:33 -06:00
parent 9f8bbb7494
commit 62c811be45
5 changed files with 1404 additions and 0 deletions

View File

@ -0,0 +1,251 @@
-- ============================================================================
-- SCHEMA: llm
-- TABLE: conversations
-- DESCRIPTION: Conversaciones con el agente LLM
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 5 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS llm;
COMMENT ON SCHEMA llm IS
'Sistema de agente LLM con conversaciones, herramientas y limites de uso';
-- Enum para tipo de conversacion
DO $$ BEGIN
CREATE TYPE llm.conversation_type AS ENUM (
'chat', -- Chat general
'trading_assistant', -- Asistente de trading
'market_analysis', -- Analisis de mercado
'education', -- Educacion/tutoria
'support', -- Soporte tecnico
'portfolio_review', -- Revision de portafolio
'strategy_builder', -- Constructor de estrategias
'code_assistant' -- Asistente de codigo
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de conversacion
DO $$ BEGIN
CREATE TYPE llm.conversation_status AS ENUM (
'active', -- Activa
'archived', -- Archivada
'deleted', -- Eliminada (soft delete)
'expired' -- Expirada por inactividad
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para modelo LLM
DO $$ BEGIN
CREATE TYPE llm.llm_model AS ENUM (
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'claude-3-5-sonnet',
'claude-3-opus',
'claude-3-5-haiku',
'gemini-pro',
'gemini-ultra',
'llama-3-70b',
'mixtral-8x7b',
'custom'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Conversaciones
CREATE TABLE IF NOT EXISTS llm.conversations (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Tipo y estado
type llm.conversation_type NOT NULL DEFAULT 'chat',
status llm.conversation_status NOT NULL DEFAULT 'active',
-- Titulo y descripcion
title VARCHAR(255),
description TEXT,
auto_title BOOLEAN NOT NULL DEFAULT TRUE, -- Generar titulo automaticamente
-- Modelo
model llm.llm_model NOT NULL DEFAULT 'gpt-4o-mini',
model_config JSONB DEFAULT '{}'::JSONB, -- Temperatura, max_tokens, etc.
-- System prompt
system_prompt TEXT,
custom_instructions TEXT,
-- Contexto
context_type VARCHAR(50), -- 'symbol', 'portfolio', 'position'
context_id UUID, -- ID del contexto
context_data JSONB DEFAULT '{}'::JSONB, -- Datos de contexto
-- Estadisticas
message_count INTEGER NOT NULL DEFAULT 0,
token_count_input INTEGER NOT NULL DEFAULT 0,
token_count_output INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
-- Costo estimado
estimated_cost_usd DECIMAL(10, 6) NOT NULL DEFAULT 0,
-- Herramientas habilitadas
tools_enabled VARCHAR(100)[], -- Herramientas disponibles
tools_auto_execute BOOLEAN NOT NULL DEFAULT FALSE, -- Ejecutar herramientas automaticamente
-- Feedback
rating INTEGER CHECK (rating BETWEEN 1 AND 5),
feedback TEXT,
rated_at TIMESTAMPTZ,
-- Compartir
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
share_code VARCHAR(20),
shared_at TIMESTAMPTZ,
-- Ultima actividad
last_message_at TIMESTAMPTZ,
last_user_message_at TIMESTAMPTZ,
-- Metadata
tags VARCHAR(50)[],
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- Para limpiar conversaciones antiguas
-- Constraints
CONSTRAINT conversations_share_code_unique UNIQUE (share_code)
);
COMMENT ON TABLE llm.conversations IS
'Conversaciones con el agente LLM por usuario';
COMMENT ON COLUMN llm.conversations.tools_enabled IS
'Herramientas habilitadas: market_data, portfolio, trading, charts, etc.';
-- Indices
CREATE INDEX IF NOT EXISTS idx_conversations_tenant
ON llm.conversations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_conversations_user
ON llm.conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_conversations_status
ON llm.conversations(status);
CREATE INDEX IF NOT EXISTS idx_conversations_active
ON llm.conversations(user_id, status, updated_at DESC)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_conversations_type
ON llm.conversations(type);
CREATE INDEX IF NOT EXISTS idx_conversations_last_message
ON llm.conversations(last_message_at DESC)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_conversations_share
ON llm.conversations(share_code)
WHERE share_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conversations_context
ON llm.conversations(context_type, context_id)
WHERE context_id IS NOT NULL;
-- Funcion de timestamp
CREATE OR REPLACE FUNCTION llm.update_llm_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger para updated_at
DROP TRIGGER IF EXISTS conversation_updated_at ON llm.conversations;
CREATE TRIGGER conversation_updated_at
BEFORE UPDATE ON llm.conversations
FOR EACH ROW
EXECUTE FUNCTION llm.update_llm_timestamp();
-- Funcion para generar share code
CREATE OR REPLACE FUNCTION llm.generate_share_code()
RETURNS VARCHAR AS $$
DECLARE
v_code VARCHAR;
v_exists BOOLEAN;
BEGIN
LOOP
v_code := UPPER(SUBSTRING(MD5(RANDOM()::TEXT || NOW()::TEXT) FROM 1 FOR 8));
SELECT EXISTS (SELECT 1 FROM llm.conversations WHERE share_code = v_code) INTO v_exists;
EXIT WHEN NOT v_exists;
END LOOP;
RETURN v_code;
END;
$$ LANGUAGE plpgsql;
-- Funcion para compartir conversacion
CREATE OR REPLACE FUNCTION llm.share_conversation(p_conversation_id UUID)
RETURNS VARCHAR AS $$
DECLARE
v_code VARCHAR;
BEGIN
v_code := llm.generate_share_code();
UPDATE llm.conversations
SET is_shared = TRUE,
share_code = v_code,
shared_at = NOW()
WHERE id = p_conversation_id;
RETURN v_code;
END;
$$ LANGUAGE plpgsql;
-- Vista de conversaciones recientes
CREATE OR REPLACE VIEW llm.v_recent_conversations AS
SELECT
id,
user_id,
type,
title,
model,
message_count,
total_tokens,
estimated_cost_usd,
last_message_at,
created_at
FROM llm.conversations
WHERE status = 'active'
ORDER BY last_message_at DESC NULLS LAST;
-- RLS Policies
ALTER TABLE llm.conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY conversations_tenant_isolation ON llm.conversations
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY conversations_user_isolation ON llm.conversations
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT USAGE ON SCHEMA llm TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON llm.conversations TO trading_app;
GRANT SELECT ON llm.conversations TO trading_readonly;
GRANT SELECT ON llm.v_recent_conversations TO trading_app;
GRANT EXECUTE ON FUNCTION llm.share_conversation TO trading_app;

View File

@ -0,0 +1,248 @@
-- ============================================================================
-- SCHEMA: llm
-- TABLE: conversation_messages
-- DESCRIPTION: Mensajes dentro de conversaciones LLM
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 5 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para rol del mensaje
DO $$ BEGIN
CREATE TYPE llm.message_role AS ENUM (
'system', -- Mensaje de sistema
'user', -- Mensaje del usuario
'assistant', -- Respuesta del asistente
'tool', -- Resultado de herramienta
'function' -- Llamada a funcion (legacy)
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado del mensaje
DO $$ BEGIN
CREATE TYPE llm.message_status AS ENUM (
'pending', -- Pendiente de procesar
'streaming', -- Streaming en progreso
'completed', -- Completado
'error', -- Error
'cancelled', -- Cancelado
'edited' -- Editado por usuario
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Mensajes
CREATE TABLE IF NOT EXISTS llm.conversation_messages (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES llm.conversations(id) ON DELETE CASCADE,
parent_message_id UUID REFERENCES llm.conversation_messages(id),
-- Rol y contenido
role llm.message_role NOT NULL,
content TEXT NOT NULL,
status llm.message_status NOT NULL DEFAULT 'completed',
-- Para mensajes de herramientas
tool_call_id VARCHAR(100),
tool_name VARCHAR(100),
tool_input JSONB,
tool_output JSONB,
-- Tokens
token_count INTEGER DEFAULT 0,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
-- Modelo usado
model VARCHAR(50),
-- Tiempos de respuesta
latency_ms INTEGER, -- Tiempo de respuesta
time_to_first_token_ms INTEGER, -- TTFT para streaming
-- Contenido estructurado
attachments JSONB DEFAULT '[]'::JSONB, -- Archivos adjuntos
citations JSONB DEFAULT '[]'::JSONB, -- Citas/referencias
code_blocks JSONB DEFAULT '[]'::JSONB, -- Bloques de codigo
-- Markdown y formato
is_markdown BOOLEAN NOT NULL DEFAULT TRUE,
rendered_html TEXT, -- HTML pre-renderizado
-- Feedback
thumbs_up BOOLEAN,
feedback_text TEXT,
feedback_at TIMESTAMPTZ,
-- Edicion
is_edited BOOLEAN NOT NULL DEFAULT FALSE,
original_content TEXT,
edited_at TIMESTAMPTZ,
-- Regeneracion
is_regenerated BOOLEAN NOT NULL DEFAULT FALSE,
regeneration_count INTEGER NOT NULL DEFAULT 0,
previous_version_id UUID,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Indices para orden
sequence_number INTEGER NOT NULL DEFAULT 0 -- Orden en la conversacion
);
COMMENT ON TABLE llm.conversation_messages IS
'Mensajes individuales dentro de conversaciones LLM';
COMMENT ON COLUMN llm.conversation_messages.tool_call_id IS
'ID de llamada a herramienta para vincular request/response';
-- Indices
CREATE INDEX IF NOT EXISTS idx_messages_conversation
ON llm.conversation_messages(conversation_id, sequence_number);
CREATE INDEX IF NOT EXISTS idx_messages_parent
ON llm.conversation_messages(parent_message_id)
WHERE parent_message_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_messages_role
ON llm.conversation_messages(role);
CREATE INDEX IF NOT EXISTS idx_messages_tool
ON llm.conversation_messages(tool_call_id)
WHERE tool_call_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_messages_created
ON llm.conversation_messages(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_feedback
ON llm.conversation_messages(thumbs_up, feedback_at)
WHERE thumbs_up IS NOT NULL;
-- Trigger para updated_at
DROP TRIGGER IF EXISTS message_updated_at ON llm.conversation_messages;
CREATE TRIGGER message_updated_at
BEFORE UPDATE ON llm.conversation_messages
FOR EACH ROW
EXECUTE FUNCTION llm.update_llm_timestamp();
-- Trigger para actualizar estadisticas de conversacion
CREATE OR REPLACE FUNCTION llm.update_conversation_stats()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE llm.conversations
SET message_count = message_count + 1,
token_count_input = token_count_input + COALESCE(NEW.prompt_tokens, 0),
token_count_output = token_count_output + COALESCE(NEW.completion_tokens, 0),
total_tokens = total_tokens + COALESCE(NEW.token_count, 0),
last_message_at = NEW.created_at,
last_user_message_at = CASE WHEN NEW.role = 'user' THEN NEW.created_at ELSE last_user_message_at END
WHERE id = NEW.conversation_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE llm.conversations
SET message_count = message_count - 1,
token_count_input = token_count_input - COALESCE(OLD.prompt_tokens, 0),
token_count_output = token_count_output - COALESCE(OLD.completion_tokens, 0),
total_tokens = total_tokens - COALESCE(OLD.token_count, 0)
WHERE id = OLD.conversation_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS message_stats_update ON llm.conversation_messages;
CREATE TRIGGER message_stats_update
AFTER INSERT OR DELETE ON llm.conversation_messages
FOR EACH ROW
EXECUTE FUNCTION llm.update_conversation_stats();
-- Trigger para asignar sequence_number
CREATE OR REPLACE FUNCTION llm.assign_message_sequence()
RETURNS TRIGGER AS $$
BEGIN
SELECT COALESCE(MAX(sequence_number), 0) + 1 INTO NEW.sequence_number
FROM llm.conversation_messages
WHERE conversation_id = NEW.conversation_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS message_sequence ON llm.conversation_messages;
CREATE TRIGGER message_sequence
BEFORE INSERT ON llm.conversation_messages
FOR EACH ROW
WHEN (NEW.sequence_number = 0)
EXECUTE FUNCTION llm.assign_message_sequence();
-- Funcion para obtener historial de conversacion
CREATE OR REPLACE FUNCTION llm.get_conversation_history(
p_conversation_id UUID,
p_limit INTEGER DEFAULT 50
)
RETURNS TABLE (
role llm.message_role,
content TEXT,
tool_name VARCHAR,
tool_output JSONB,
created_at TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
m.role,
m.content,
m.tool_name,
m.tool_output,
m.created_at
FROM llm.conversation_messages m
WHERE m.conversation_id = p_conversation_id
AND m.status = 'completed'
ORDER BY m.sequence_number DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;
-- Vista de mensajes con feedback negativo (para mejorar)
CREATE OR REPLACE VIEW llm.v_messages_negative_feedback AS
SELECT
m.id,
m.conversation_id,
c.type AS conversation_type,
m.role,
m.content,
m.model,
m.feedback_text,
m.created_at
FROM llm.conversation_messages m
JOIN llm.conversations c ON m.conversation_id = c.id
WHERE m.thumbs_up = FALSE
ORDER BY m.feedback_at DESC;
-- RLS Policies (hereda de conversations via JOIN)
ALTER TABLE llm.conversation_messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY messages_via_conversation ON llm.conversation_messages
FOR ALL
USING (
conversation_id IN (
SELECT id FROM llm.conversations
WHERE user_id = current_setting('app.current_user_id', true)::UUID
)
);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON llm.conversation_messages TO trading_app;
GRANT SELECT ON llm.conversation_messages TO trading_readonly;
GRANT SELECT ON llm.v_messages_negative_feedback TO trading_app;
GRANT EXECUTE ON FUNCTION llm.get_conversation_history TO trading_app;

View File

@ -0,0 +1,263 @@
-- ============================================================================
-- SCHEMA: llm
-- TABLE: llm_tools_usage
-- DESCRIPTION: Registro de uso de herramientas por el agente LLM
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 5 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para categoria de herramienta
DO $$ BEGIN
CREATE TYPE llm.tool_category AS ENUM (
'market_data', -- Datos de mercado
'trading', -- Operaciones de trading
'portfolio', -- Gestion de portafolio
'analysis', -- Analisis tecnico/fundamental
'charts', -- Graficos
'alerts', -- Alertas
'education', -- Educacion
'news', -- Noticias
'calendar', -- Calendario economico
'calculations', -- Calculos financieros
'external_api', -- APIs externas
'system' -- Sistema
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de ejecucion
DO $$ BEGIN
CREATE TYPE llm.tool_execution_status AS ENUM (
'pending', -- Pendiente
'executing', -- Ejecutando
'success', -- Exitoso
'error', -- Error
'timeout', -- Timeout
'cancelled', -- Cancelado
'rate_limited', -- Rate limited
'permission_denied' -- Sin permisos
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Uso de Herramientas
CREATE TABLE IF NOT EXISTS llm.llm_tools_usage (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Referencias
conversation_id UUID REFERENCES llm.conversations(id) ON DELETE SET NULL,
message_id UUID REFERENCES llm.conversation_messages(id) ON DELETE SET NULL,
-- Herramienta
tool_name VARCHAR(100) NOT NULL,
tool_category llm.tool_category NOT NULL,
tool_version VARCHAR(20),
-- Ejecucion
status llm.tool_execution_status NOT NULL DEFAULT 'pending',
execution_time_ms INTEGER,
-- Input/Output
input_params JSONB NOT NULL DEFAULT '{}'::JSONB,
output_result JSONB,
output_size_bytes INTEGER,
-- Error
error_code VARCHAR(50),
error_message TEXT,
error_details JSONB,
-- Contexto
context_symbol VARCHAR(20),
context_timeframe VARCHAR(10),
context_data JSONB DEFAULT '{}'::JSONB,
-- Rate limiting
rate_limit_bucket VARCHAR(50),
rate_limit_remaining INTEGER,
-- Costo
cost_units DECIMAL(10, 4) DEFAULT 0, -- Unidades de costo (API calls, etc)
cost_usd DECIMAL(10, 6) DEFAULT 0, -- Costo en USD
-- Auditoria
ip_address INET,
user_agent TEXT,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE llm.llm_tools_usage IS
'Registro de todas las ejecuciones de herramientas por el agente LLM';
COMMENT ON COLUMN llm.llm_tools_usage.cost_units IS
'Unidades de costo internas (ej: 1 API call = 1 unit)';
-- Indices
CREATE INDEX IF NOT EXISTS idx_tools_usage_tenant
ON llm.llm_tools_usage(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tools_usage_user
ON llm.llm_tools_usage(user_id);
CREATE INDEX IF NOT EXISTS idx_tools_usage_conversation
ON llm.llm_tools_usage(conversation_id)
WHERE conversation_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tools_usage_tool
ON llm.llm_tools_usage(tool_name, requested_at DESC);
CREATE INDEX IF NOT EXISTS idx_tools_usage_category
ON llm.llm_tools_usage(tool_category, requested_at DESC);
CREATE INDEX IF NOT EXISTS idx_tools_usage_status
ON llm.llm_tools_usage(status);
CREATE INDEX IF NOT EXISTS idx_tools_usage_errors
ON llm.llm_tools_usage(status, tool_name)
WHERE status IN ('error', 'timeout');
CREATE INDEX IF NOT EXISTS idx_tools_usage_date
ON llm.llm_tools_usage(requested_at DESC);
-- Indice BRIN para datos temporales
CREATE INDEX IF NOT EXISTS idx_tools_usage_brin
ON llm.llm_tools_usage USING BRIN (requested_at);
-- Funcion para registrar inicio de ejecucion
CREATE OR REPLACE FUNCTION llm.start_tool_execution(
p_tool_usage_id UUID
)
RETURNS VOID AS $$
BEGIN
UPDATE llm.llm_tools_usage
SET status = 'executing',
started_at = NOW()
WHERE id = p_tool_usage_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para registrar fin exitoso
CREATE OR REPLACE FUNCTION llm.complete_tool_execution(
p_tool_usage_id UUID,
p_output JSONB
)
RETURNS VOID AS $$
DECLARE
v_started_at TIMESTAMPTZ;
BEGIN
SELECT started_at INTO v_started_at
FROM llm.llm_tools_usage
WHERE id = p_tool_usage_id;
UPDATE llm.llm_tools_usage
SET status = 'success',
completed_at = NOW(),
execution_time_ms = EXTRACT(EPOCH FROM (NOW() - v_started_at)) * 1000,
output_result = p_output,
output_size_bytes = LENGTH(p_output::TEXT)
WHERE id = p_tool_usage_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para registrar error
CREATE OR REPLACE FUNCTION llm.fail_tool_execution(
p_tool_usage_id UUID,
p_error_code VARCHAR,
p_error_message TEXT,
p_error_details JSONB DEFAULT NULL
)
RETURNS VOID AS $$
BEGIN
UPDATE llm.llm_tools_usage
SET status = 'error',
completed_at = NOW(),
error_code = p_error_code,
error_message = p_error_message,
error_details = p_error_details
WHERE id = p_tool_usage_id;
END;
$$ LANGUAGE plpgsql;
-- Vista de estadisticas de herramientas
CREATE OR REPLACE VIEW llm.v_tool_usage_stats AS
SELECT
tool_name,
tool_category,
COUNT(*) AS total_calls,
COUNT(*) FILTER (WHERE status = 'success') AS successful_calls,
COUNT(*) FILTER (WHERE status = 'error') AS failed_calls,
ROUND((COUNT(*) FILTER (WHERE status = 'success')::DECIMAL
/ NULLIF(COUNT(*), 0) * 100), 2) AS success_rate,
ROUND(AVG(execution_time_ms)::NUMERIC, 2) AS avg_execution_ms,
ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY execution_time_ms)::NUMERIC, 2) AS p95_execution_ms,
SUM(cost_units) AS total_cost_units,
SUM(cost_usd) AS total_cost_usd
FROM llm.llm_tools_usage
WHERE completed_at IS NOT NULL
GROUP BY tool_name, tool_category
ORDER BY total_calls DESC;
-- Vista de errores recientes
CREATE OR REPLACE VIEW llm.v_tool_errors AS
SELECT
id,
user_id,
tool_name,
tool_category,
error_code,
error_message,
input_params,
requested_at
FROM llm.llm_tools_usage
WHERE status IN ('error', 'timeout')
ORDER BY requested_at DESC
LIMIT 100;
-- Vista de uso por usuario
CREATE OR REPLACE VIEW llm.v_user_tool_usage AS
SELECT
user_id,
tool_category,
COUNT(*) AS total_calls,
SUM(cost_units) AS total_cost_units,
SUM(cost_usd) AS total_cost_usd,
MAX(requested_at) AS last_used_at
FROM llm.llm_tools_usage
WHERE requested_at >= NOW() - INTERVAL '30 days'
GROUP BY user_id, tool_category
ORDER BY user_id, total_calls DESC;
-- RLS Policies
ALTER TABLE llm.llm_tools_usage ENABLE ROW LEVEL SECURITY;
CREATE POLICY tools_usage_tenant_isolation ON llm.llm_tools_usage
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY tools_usage_user_isolation ON llm.llm_tools_usage
FOR SELECT
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON llm.llm_tools_usage TO trading_app;
GRANT SELECT ON llm.llm_tools_usage TO trading_readonly;
GRANT SELECT ON llm.v_tool_usage_stats TO trading_app;
GRANT SELECT ON llm.v_tool_errors TO trading_app;
GRANT SELECT ON llm.v_user_tool_usage TO trading_app;
GRANT EXECUTE ON FUNCTION llm.start_tool_execution TO trading_app;
GRANT EXECUTE ON FUNCTION llm.complete_tool_execution TO trading_app;
GRANT EXECUTE ON FUNCTION llm.fail_tool_execution TO trading_app;

View File

@ -0,0 +1,284 @@
-- ============================================================================
-- SCHEMA: llm
-- TABLE: llm_proactive_notifications
-- DESCRIPTION: Notificaciones proactivas generadas por el agente LLM
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 5 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para tipo de notificacion proactiva
DO $$ BEGIN
CREATE TYPE llm.proactive_notification_type AS ENUM (
'market_alert', -- Alerta de mercado
'trading_opportunity', -- Oportunidad de trading
'portfolio_update', -- Actualizacion de portafolio
'risk_warning', -- Advertencia de riesgo
'news_digest', -- Resumen de noticias
'learning_reminder', -- Recordatorio de aprendizaje
'goal_progress', -- Progreso hacia metas
'weekly_summary', -- Resumen semanal
'strategy_insight', -- Insight de estrategia
'market_open', -- Apertura de mercado
'market_close', -- Cierre de mercado
'custom' -- Personalizado
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para prioridad
DO $$ BEGIN
CREATE TYPE llm.notification_priority AS ENUM (
'low',
'normal',
'high',
'urgent'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de notificacion
DO $$ BEGIN
CREATE TYPE llm.notification_status AS ENUM (
'pending', -- Pendiente de enviar
'scheduled', -- Programada
'sent', -- Enviada
'delivered', -- Entregada
'read', -- Leida
'clicked', -- Click en accion
'dismissed', -- Descartada
'failed', -- Fallo en envio
'expired' -- Expirada
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Notificaciones Proactivas
CREATE TABLE IF NOT EXISTS llm.llm_proactive_notifications (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Tipo y prioridad
type llm.proactive_notification_type NOT NULL,
priority llm.notification_priority NOT NULL DEFAULT 'normal',
status llm.notification_status NOT NULL DEFAULT 'pending',
-- Contenido
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
summary TEXT, -- Resumen corto para push
-- Accion
action_type VARCHAR(50), -- 'open_chart', 'view_position', 'start_lesson'
action_url TEXT,
action_data JSONB DEFAULT '{}'::JSONB,
-- Contexto
context_type VARCHAR(50), -- 'symbol', 'position', 'course'
context_id UUID,
related_symbols VARCHAR(20)[],
-- Canales de entrega
channels VARCHAR(20)[] DEFAULT ARRAY['in_app'], -- 'in_app', 'push', 'email', 'sms'
channel_results JSONB DEFAULT '{}'::JSONB, -- Resultado por canal
-- Generacion
generated_by VARCHAR(100), -- Modelo/servicio que genero
generation_reason TEXT,
generation_data JSONB DEFAULT '{}'::JSONB, -- Datos usados para generar
-- Confianza y relevancia
confidence_score DECIMAL(5, 4), -- 0-1
relevance_score DECIMAL(5, 4), -- 0-1
personalization_score DECIMAL(5, 4), -- 0-1
-- Programacion
scheduled_for TIMESTAMPTZ,
send_window_start TIME, -- Ventana de envio preferida
send_window_end TIME,
timezone VARCHAR(50) DEFAULT 'UTC',
-- Envio
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
clicked_at TIMESTAMPTZ,
dismissed_at TIMESTAMPTZ,
-- Expiracion
expires_at TIMESTAMPTZ,
-- Feedback
user_feedback VARCHAR(20), -- 'helpful', 'not_helpful', 'spam'
feedback_text TEXT,
feedback_at TIMESTAMPTZ,
-- Agrupacion
group_key VARCHAR(100), -- Para agrupar notificaciones similares
is_grouped BOOLEAN NOT NULL DEFAULT FALSE,
group_count INTEGER DEFAULT 1,
-- Metadata
tags VARCHAR(50)[],
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE llm.llm_proactive_notifications IS
'Notificaciones proactivas generadas por el agente LLM para engagement del usuario';
COMMENT ON COLUMN llm.llm_proactive_notifications.confidence_score IS
'Confianza del modelo en la relevancia de la notificacion';
-- Indices
CREATE INDEX IF NOT EXISTS idx_proactive_notif_tenant
ON llm.llm_proactive_notifications(tenant_id);
CREATE INDEX IF NOT EXISTS idx_proactive_notif_user
ON llm.llm_proactive_notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_proactive_notif_status
ON llm.llm_proactive_notifications(status);
CREATE INDEX IF NOT EXISTS idx_proactive_notif_pending
ON llm.llm_proactive_notifications(user_id, status, scheduled_for)
WHERE status IN ('pending', 'scheduled');
CREATE INDEX IF NOT EXISTS idx_proactive_notif_type
ON llm.llm_proactive_notifications(type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_proactive_notif_priority
ON llm.llm_proactive_notifications(priority, status)
WHERE priority IN ('high', 'urgent') AND status = 'pending';
CREATE INDEX IF NOT EXISTS idx_proactive_notif_scheduled
ON llm.llm_proactive_notifications(scheduled_for)
WHERE status = 'scheduled' AND scheduled_for IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_proactive_notif_unread
ON llm.llm_proactive_notifications(user_id, read_at)
WHERE status IN ('sent', 'delivered') AND read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_proactive_notif_group
ON llm.llm_proactive_notifications(group_key)
WHERE group_key IS NOT NULL;
-- Trigger para updated_at
DROP TRIGGER IF EXISTS proactive_notif_updated_at ON llm.llm_proactive_notifications;
CREATE TRIGGER proactive_notif_updated_at
BEFORE UPDATE ON llm.llm_proactive_notifications
FOR EACH ROW
EXECUTE FUNCTION llm.update_llm_timestamp();
-- Funcion para marcar como leida
CREATE OR REPLACE FUNCTION llm.mark_notification_read(
p_notification_id UUID
)
RETURNS VOID AS $$
BEGIN
UPDATE llm.llm_proactive_notifications
SET status = 'read',
read_at = NOW()
WHERE id = p_notification_id
AND read_at IS NULL;
END;
$$ LANGUAGE plpgsql;
-- Funcion para obtener notificaciones pendientes
CREATE OR REPLACE FUNCTION llm.get_pending_notifications(
p_user_id UUID,
p_limit INTEGER DEFAULT 20
)
RETURNS SETOF llm.llm_proactive_notifications AS $$
BEGIN
RETURN QUERY
SELECT *
FROM llm.llm_proactive_notifications
WHERE user_id = p_user_id
AND status IN ('sent', 'delivered')
AND read_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY
CASE priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
ELSE 4
END,
created_at DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;
-- Funcion para contar no leidas
CREATE OR REPLACE FUNCTION llm.count_unread_notifications(p_user_id UUID)
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
SELECT COUNT(*) INTO v_count
FROM llm.llm_proactive_notifications
WHERE user_id = p_user_id
AND status IN ('sent', 'delivered')
AND read_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW());
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
-- Vista de notificaciones para enviar
CREATE OR REPLACE VIEW llm.v_notifications_to_send AS
SELECT *
FROM llm.llm_proactive_notifications
WHERE status = 'scheduled'
AND scheduled_for <= NOW()
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY priority DESC, scheduled_for;
-- Vista de efectividad de notificaciones
CREATE OR REPLACE VIEW llm.v_notification_effectiveness AS
SELECT
type,
priority,
COUNT(*) AS total_sent,
COUNT(*) FILTER (WHERE read_at IS NOT NULL) AS read_count,
COUNT(*) FILTER (WHERE clicked_at IS NOT NULL) AS clicked_count,
ROUND((COUNT(*) FILTER (WHERE read_at IS NOT NULL)::DECIMAL
/ NULLIF(COUNT(*), 0) * 100), 2) AS read_rate,
ROUND((COUNT(*) FILTER (WHERE clicked_at IS NOT NULL)::DECIMAL
/ NULLIF(COUNT(*) FILTER (WHERE read_at IS NOT NULL), 0) * 100), 2) AS click_rate,
COUNT(*) FILTER (WHERE user_feedback = 'helpful') AS helpful_count,
COUNT(*) FILTER (WHERE user_feedback = 'not_helpful') AS not_helpful_count
FROM llm.llm_proactive_notifications
WHERE status IN ('sent', 'delivered', 'read', 'clicked', 'dismissed')
GROUP BY type, priority
ORDER BY total_sent DESC;
-- RLS Policies
ALTER TABLE llm.llm_proactive_notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY proactive_notif_tenant ON llm.llm_proactive_notifications
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY proactive_notif_user ON llm.llm_proactive_notifications
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON llm.llm_proactive_notifications TO trading_app;
GRANT SELECT ON llm.llm_proactive_notifications TO trading_readonly;
GRANT SELECT ON llm.v_notifications_to_send TO trading_app;
GRANT SELECT ON llm.v_notification_effectiveness TO trading_app;
GRANT EXECUTE ON FUNCTION llm.mark_notification_read TO trading_app;
GRANT EXECUTE ON FUNCTION llm.get_pending_notifications TO trading_app;
GRANT EXECUTE ON FUNCTION llm.count_unread_notifications TO trading_app;

View File

@ -0,0 +1,358 @@
-- ============================================================================
-- SCHEMA: llm
-- TABLE: llm_usage_limits
-- DESCRIPTION: Limites de uso del agente LLM por usuario/plan
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 5 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para periodo de reset
DO $$ BEGIN
CREATE TYPE llm.reset_period AS ENUM (
'hourly',
'daily',
'weekly',
'monthly',
'never' -- Sin reset (lifetime limit)
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Limites de Uso
CREATE TABLE IF NOT EXISTS llm.llm_usage_limits (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Plan VIP asociado
vip_plan_id UUID, -- FK a vip.plans si existe
vip_level VARCHAR(20), -- 'free', 'basic', 'pro', 'enterprise'
-- Limites de mensajes
messages_limit INTEGER NOT NULL DEFAULT 100, -- Mensajes por periodo
messages_used INTEGER NOT NULL DEFAULT 0,
messages_reset_period llm.reset_period NOT NULL DEFAULT 'daily',
messages_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 day',
-- Limites de tokens
tokens_limit INTEGER NOT NULL DEFAULT 100000, -- Tokens por periodo
tokens_used INTEGER NOT NULL DEFAULT 0,
tokens_reset_period llm.reset_period NOT NULL DEFAULT 'monthly',
tokens_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 month',
-- Limites de herramientas
tools_limit INTEGER NOT NULL DEFAULT 50, -- Llamadas a herramientas por periodo
tools_used INTEGER NOT NULL DEFAULT 0,
tools_reset_period llm.reset_period NOT NULL DEFAULT 'daily',
tools_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 day',
-- Limites de conversaciones
conversations_limit INTEGER, -- Conversaciones activas max (NULL = unlimited)
conversations_active INTEGER NOT NULL DEFAULT 0,
-- Modelos permitidos
allowed_models llm.llm_model[] DEFAULT ARRAY['gpt-4o-mini']::llm.llm_model[],
default_model llm.llm_model NOT NULL DEFAULT 'gpt-4o-mini',
-- Herramientas permitidas
allowed_tools VARCHAR(100)[], -- NULL = todas
blocked_tools VARCHAR(100)[], -- Herramientas bloqueadas
-- Features
can_use_vision BOOLEAN NOT NULL DEFAULT FALSE,
can_use_voice BOOLEAN NOT NULL DEFAULT FALSE,
can_share_conversations BOOLEAN NOT NULL DEFAULT TRUE,
can_export_conversations BOOLEAN NOT NULL DEFAULT TRUE,
can_use_code_interpreter BOOLEAN NOT NULL DEFAULT FALSE,
-- Rate limiting
requests_per_minute INTEGER DEFAULT 10,
requests_per_hour INTEGER DEFAULT 100,
current_minute_requests INTEGER NOT NULL DEFAULT 0,
current_hour_requests INTEGER NOT NULL DEFAULT 0,
minute_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 minute',
hour_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 hour',
-- Costo
monthly_budget_usd DECIMAL(10, 2), -- Presupuesto mensual
monthly_spent_usd DECIMAL(10, 6) NOT NULL DEFAULT 0,
budget_alert_threshold DECIMAL(5, 2) DEFAULT 80, -- % para alerta
budget_alert_sent BOOLEAN NOT NULL DEFAULT FALSE,
-- Overage (exceso)
allow_overage BOOLEAN NOT NULL DEFAULT FALSE,
overage_rate_multiplier DECIMAL(5, 2) DEFAULT 1.5, -- Multiplicador de costo en overage
overage_limit_percent DECIMAL(5, 2) DEFAULT 20, -- % max de overage permitido
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_suspended BOOLEAN NOT NULL DEFAULT FALSE,
suspension_reason TEXT,
suspended_at TIMESTAMPTZ,
-- Notificaciones
notify_at_percent INTEGER[] DEFAULT ARRAY[50, 80, 100],
last_notification_percent INTEGER,
-- Metadata
custom_limits JSONB DEFAULT '{}'::JSONB, -- Limites personalizados
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT usage_limits_user_unique UNIQUE (user_id),
CONSTRAINT usage_limits_valid_percents CHECK (
budget_alert_threshold BETWEEN 0 AND 100
AND overage_limit_percent BETWEEN 0 AND 100
)
);
COMMENT ON TABLE llm.llm_usage_limits IS
'Limites de uso del agente LLM por usuario, vinculado a planes VIP';
COMMENT ON COLUMN llm.llm_usage_limits.allow_overage IS
'Si TRUE, permite uso adicional con costo extra cuando se alcanza el limite';
-- Indices
CREATE INDEX IF NOT EXISTS idx_usage_limits_tenant
ON llm.llm_usage_limits(tenant_id);
CREATE INDEX IF NOT EXISTS idx_usage_limits_user
ON llm.llm_usage_limits(user_id);
CREATE INDEX IF NOT EXISTS idx_usage_limits_vip
ON llm.llm_usage_limits(vip_level);
CREATE INDEX IF NOT EXISTS idx_usage_limits_reset
ON llm.llm_usage_limits(messages_reset_at, tokens_reset_at);
CREATE INDEX IF NOT EXISTS idx_usage_limits_suspended
ON llm.llm_usage_limits(is_suspended)
WHERE is_suspended = TRUE;
CREATE INDEX IF NOT EXISTS idx_usage_limits_budget_alert
ON llm.llm_usage_limits(monthly_budget_usd, monthly_spent_usd)
WHERE monthly_budget_usd IS NOT NULL;
-- Trigger para updated_at
DROP TRIGGER IF EXISTS usage_limits_updated_at ON llm.llm_usage_limits;
CREATE TRIGGER usage_limits_updated_at
BEFORE UPDATE ON llm.llm_usage_limits
FOR EACH ROW
EXECUTE FUNCTION llm.update_llm_timestamp();
-- Funcion para verificar y resetear limites
CREATE OR REPLACE FUNCTION llm.check_and_reset_limits(p_user_id UUID)
RETURNS llm.llm_usage_limits AS $$
DECLARE
v_limits llm.llm_usage_limits;
BEGIN
SELECT * INTO v_limits FROM llm.llm_usage_limits WHERE user_id = p_user_id FOR UPDATE;
IF NOT FOUND THEN
RETURN NULL;
END IF;
-- Reset mensajes si es necesario
IF v_limits.messages_reset_at <= NOW() THEN
UPDATE llm.llm_usage_limits
SET messages_used = 0,
messages_reset_at = NOW() + (
CASE messages_reset_period
WHEN 'hourly' THEN INTERVAL '1 hour'
WHEN 'daily' THEN INTERVAL '1 day'
WHEN 'weekly' THEN INTERVAL '1 week'
WHEN 'monthly' THEN INTERVAL '1 month'
ELSE INTERVAL '100 years'
END
)
WHERE user_id = p_user_id;
END IF;
-- Reset tokens si es necesario
IF v_limits.tokens_reset_at <= NOW() THEN
UPDATE llm.llm_usage_limits
SET tokens_used = 0,
tokens_reset_at = NOW() + (
CASE tokens_reset_period
WHEN 'hourly' THEN INTERVAL '1 hour'
WHEN 'daily' THEN INTERVAL '1 day'
WHEN 'weekly' THEN INTERVAL '1 week'
WHEN 'monthly' THEN INTERVAL '1 month'
ELSE INTERVAL '100 years'
END
)
WHERE user_id = p_user_id;
END IF;
-- Reset herramientas si es necesario
IF v_limits.tools_reset_at <= NOW() THEN
UPDATE llm.llm_usage_limits
SET tools_used = 0,
tools_reset_at = NOW() + (
CASE tools_reset_period
WHEN 'hourly' THEN INTERVAL '1 hour'
WHEN 'daily' THEN INTERVAL '1 day'
WHEN 'weekly' THEN INTERVAL '1 week'
WHEN 'monthly' THEN INTERVAL '1 month'
ELSE INTERVAL '100 years'
END
)
WHERE user_id = p_user_id;
END IF;
-- Reset rate limits
IF v_limits.minute_reset_at <= NOW() THEN
UPDATE llm.llm_usage_limits
SET current_minute_requests = 0,
minute_reset_at = NOW() + INTERVAL '1 minute'
WHERE user_id = p_user_id;
END IF;
IF v_limits.hour_reset_at <= NOW() THEN
UPDATE llm.llm_usage_limits
SET current_hour_requests = 0,
hour_reset_at = NOW() + INTERVAL '1 hour'
WHERE user_id = p_user_id;
END IF;
-- Retornar limites actualizados
SELECT * INTO v_limits FROM llm.llm_usage_limits WHERE user_id = p_user_id;
RETURN v_limits;
END;
$$ LANGUAGE plpgsql;
-- Funcion para verificar si puede enviar mensaje
CREATE OR REPLACE FUNCTION llm.can_send_message(p_user_id UUID)
RETURNS TABLE (
allowed BOOLEAN,
reason TEXT,
messages_remaining INTEGER,
tokens_remaining INTEGER
) AS $$
DECLARE
v_limits llm.llm_usage_limits;
BEGIN
v_limits := llm.check_and_reset_limits(p_user_id);
IF v_limits IS NULL THEN
RETURN QUERY SELECT FALSE, 'User limits not configured'::TEXT, 0, 0;
RETURN;
END IF;
IF v_limits.is_suspended THEN
RETURN QUERY SELECT FALSE, v_limits.suspension_reason, 0, 0;
RETURN;
END IF;
IF NOT v_limits.is_active THEN
RETURN QUERY SELECT FALSE, 'Account inactive'::TEXT, 0, 0;
RETURN;
END IF;
IF v_limits.messages_used >= v_limits.messages_limit AND NOT v_limits.allow_overage THEN
RETURN QUERY SELECT FALSE, 'Message limit reached'::TEXT,
0, v_limits.tokens_limit - v_limits.tokens_used;
RETURN;
END IF;
IF v_limits.current_minute_requests >= v_limits.requests_per_minute THEN
RETURN QUERY SELECT FALSE, 'Rate limit: too many requests per minute'::TEXT,
v_limits.messages_limit - v_limits.messages_used,
v_limits.tokens_limit - v_limits.tokens_used;
RETURN;
END IF;
RETURN QUERY SELECT TRUE, NULL::TEXT,
v_limits.messages_limit - v_limits.messages_used,
v_limits.tokens_limit - v_limits.tokens_used;
END;
$$ LANGUAGE plpgsql;
-- Funcion para incrementar uso
CREATE OR REPLACE FUNCTION llm.increment_usage(
p_user_id UUID,
p_messages INTEGER DEFAULT 1,
p_tokens INTEGER DEFAULT 0,
p_tools INTEGER DEFAULT 0,
p_cost_usd DECIMAL DEFAULT 0
)
RETURNS VOID AS $$
BEGIN
UPDATE llm.llm_usage_limits
SET messages_used = messages_used + p_messages,
tokens_used = tokens_used + p_tokens,
tools_used = tools_used + p_tools,
monthly_spent_usd = monthly_spent_usd + p_cost_usd,
current_minute_requests = current_minute_requests + 1,
current_hour_requests = current_hour_requests + 1
WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;
-- Vista de uso actual
CREATE OR REPLACE VIEW llm.v_current_usage AS
SELECT
user_id,
vip_level,
messages_used,
messages_limit,
ROUND((messages_used::DECIMAL / NULLIF(messages_limit, 0) * 100), 1) AS messages_percent,
tokens_used,
tokens_limit,
ROUND((tokens_used::DECIMAL / NULLIF(tokens_limit, 0) * 100), 1) AS tokens_percent,
tools_used,
tools_limit,
monthly_spent_usd,
monthly_budget_usd,
messages_reset_at,
tokens_reset_at,
is_active,
is_suspended
FROM llm.llm_usage_limits;
-- Vista de usuarios cerca del limite
CREATE OR REPLACE VIEW llm.v_users_near_limit AS
SELECT
user_id,
vip_level,
messages_used,
messages_limit,
ROUND((messages_used::DECIMAL / NULLIF(messages_limit, 0) * 100), 1) AS messages_percent,
tokens_used,
tokens_limit,
ROUND((tokens_used::DECIMAL / NULLIF(tokens_limit, 0) * 100), 1) AS tokens_percent
FROM llm.llm_usage_limits
WHERE (messages_used::DECIMAL / NULLIF(messages_limit, 0) >= 0.8)
OR (tokens_used::DECIMAL / NULLIF(tokens_limit, 0) >= 0.8)
ORDER BY
GREATEST(
messages_used::DECIMAL / NULLIF(messages_limit, 0),
tokens_used::DECIMAL / NULLIF(tokens_limit, 0)
) DESC;
-- RLS Policies
ALTER TABLE llm.llm_usage_limits ENABLE ROW LEVEL SECURITY;
CREATE POLICY usage_limits_tenant ON llm.llm_usage_limits
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY usage_limits_user ON llm.llm_usage_limits
FOR SELECT
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON llm.llm_usage_limits TO trading_app;
GRANT SELECT ON llm.llm_usage_limits TO trading_readonly;
GRANT SELECT ON llm.v_current_usage TO trading_app;
GRANT SELECT ON llm.v_users_near_limit TO trading_app;
GRANT EXECUTE ON FUNCTION llm.check_and_reset_limits TO trading_app;
GRANT EXECUTE ON FUNCTION llm.can_send_message TO trading_app;
GRANT EXECUTE ON FUNCTION llm.increment_usage TO trading_app;