From 62c811be451c09223123558b60b639efa370a48e Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Fri, 16 Jan 2026 20:13:33 -0600 Subject: [PATCH] [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 --- ddl/schemas/llm/tables/001_conversations.sql | 251 ++++++++++++ .../llm/tables/002_conversation_messages.sql | 248 ++++++++++++ .../llm/tables/003_llm_tools_usage.sql | 263 +++++++++++++ .../004_llm_proactive_notifications.sql | 284 ++++++++++++++ .../llm/tables/005_llm_usage_limits.sql | 358 ++++++++++++++++++ 5 files changed, 1404 insertions(+) create mode 100644 ddl/schemas/llm/tables/001_conversations.sql create mode 100644 ddl/schemas/llm/tables/002_conversation_messages.sql create mode 100644 ddl/schemas/llm/tables/003_llm_tools_usage.sql create mode 100644 ddl/schemas/llm/tables/004_llm_proactive_notifications.sql create mode 100644 ddl/schemas/llm/tables/005_llm_usage_limits.sql diff --git a/ddl/schemas/llm/tables/001_conversations.sql b/ddl/schemas/llm/tables/001_conversations.sql new file mode 100644 index 0000000..91a51b7 --- /dev/null +++ b/ddl/schemas/llm/tables/001_conversations.sql @@ -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; diff --git a/ddl/schemas/llm/tables/002_conversation_messages.sql b/ddl/schemas/llm/tables/002_conversation_messages.sql new file mode 100644 index 0000000..88d56a8 --- /dev/null +++ b/ddl/schemas/llm/tables/002_conversation_messages.sql @@ -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; diff --git a/ddl/schemas/llm/tables/003_llm_tools_usage.sql b/ddl/schemas/llm/tables/003_llm_tools_usage.sql new file mode 100644 index 0000000..29a58d8 --- /dev/null +++ b/ddl/schemas/llm/tables/003_llm_tools_usage.sql @@ -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; diff --git a/ddl/schemas/llm/tables/004_llm_proactive_notifications.sql b/ddl/schemas/llm/tables/004_llm_proactive_notifications.sql new file mode 100644 index 0000000..7544c27 --- /dev/null +++ b/ddl/schemas/llm/tables/004_llm_proactive_notifications.sql @@ -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; diff --git a/ddl/schemas/llm/tables/005_llm_usage_limits.sql b/ddl/schemas/llm/tables/005_llm_usage_limits.sql new file mode 100644 index 0000000..60de33e --- /dev/null +++ b/ddl/schemas/llm/tables/005_llm_usage_limits.sql @@ -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;