[P2606] WebServer NGINX: plan actualizado, ficha nodo, HTTPS operativo

- P2606: Plan v1.1 EN EJECUCIÓN. Fases 1-3 completadas.
- Ficha nodo srvv-nginx-rm.md. P2606 en adn/07_proyectos.md.
- Incluye cambios acumulados de sesiones anteriores.
This commit is contained in:
Ricardo Monla
2026-03-28 12:18:42 -03:00
parent 97d9b777b4
commit 160da47f96
78 changed files with 6461 additions and 624 deletions
+103
View File
@@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'pg'
require 'json'
module ADN
module AmbitosDB
module BaseOperations
# Listar ámbitos
def list_ambitos(activo: nil)
query = "SELECT a.*, p.nombre as parent_nombre,
(SELECT COUNT(*) FROM bitacoras.nodos WHERE ambito_id = a.id) as count_nodos
FROM bitacoras.ambitos a
LEFT JOIN bitacoras.ambitos p ON a.parent_id = p.id"
params = []
unless activo.nil?
query += " WHERE a.activo = $1"
params << (activo ? 't' : 'f')
end
query += " ORDER BY a.parent_id NULLS FIRST, a.nombre ASC"
res = execute(query, params)
res.map do |r|
r['activo'] = (r['activo'] == 't') if r.key?('activo')
r
end
end
# Obtener información de un ámbito
def get_ambito(id_or_nombre)
query = "SELECT a.*, p.nombre as parent_nombre,
(SELECT COUNT(*) FROM bitacoras.nodos WHERE ambito_id = a.id) as count_nodos
FROM bitacoras.ambitos a
LEFT JOIN bitacoras.ambitos p ON a.parent_id = p.id
WHERE a.id = $1 OR a.nombre = $2 LIMIT 1"
id = id_or_nombre.to_i > 0 ? id_or_nombre.to_i : 0
res = execute(query, [id, id_or_nombre.to_s])
return nil if res.ntuples == 0
row = res[0]
row['activo'] = (row['activo'] == 't') if row.key?('activo')
row
end
# Crear un nuevo ámbito
def create_ambito(nombre:, descripcion: nil, parent_id: nil, activo: true)
query = "INSERT INTO bitacoras.ambitos (nombre, descripcion, parent_id, activo)
VALUES ($1, $2, $3, $4) RETURNING *"
params = [nombre, descripcion, parent_id, activo ? 't' : 'f']
res = execute(query, params)
row = res[0]
row['activo'] = (row['activo'] == 't') if row.key?('activo')
row
end
# Actualizar ámbito
def update_ambito(id, campos = {})
return nil if campos.empty?
set_parts = []
params = []
campos.each_with_index do |(k, v), i|
set_parts << "#{k} = $#{i + 1}"
params << (v.is_a?(TrueClass) || v.is_a?(FalseClass) ? (v ? 't' : 'f') : v)
end
params << id
query = "UPDATE bitacoras.ambitos SET #{set_parts.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $#{params.length} RETURNING *"
res = execute(query, params)
return nil if res.ntuples == 0
row = res[0]
row['activo'] = (row['activo'] == 't') if row.key?('activo')
row
end
# Eliminar ámbito
def delete_ambito(id)
query = "DELETE FROM bitacoras.ambitos WHERE id = $1 RETURNING *"
res = execute(query, [id.to_i])
res.ntuples > 0
end
# Listar nodos de un ámbito
def get_nodos_by_ambito(ambito_id)
query = "SELECT * FROM bitacoras.nodos WHERE ambito_id = $1 ORDER BY nombre ASC"
res = execute(query, [ambito_id.to_i])
res.map do |r|
r['activo'] = (r['activo'] == 't') if r.key?('activo')
r
end
end
end
end
end
+129 -28
View File
@@ -30,9 +30,13 @@ require 'yaml'
require 'json'
require 'date'
require 'time'
require_relative 'mejoras_sensor'
require_relative 'ambito_db'
module BitacorasDB
class BitacoraDB
include ADN::AmbitosDB::BaseOperations
# Constantes para el esquema y tablas
SCHEMA = 'bitacoras'
TABLES = {
@@ -60,6 +64,7 @@ module BitacorasDB
@connection = nil
@logger = logger || default_logger
@connected = false
@activo = ::ADN::MejorasSensor.activo?
end
# Carga la configuración desde el archivo YAML
@@ -197,18 +202,24 @@ module BitacorasDB
#
# @param fecha [Date, String] Fecha de la bitácora
# @param resumen [String, nil] Resumen opcional
# @param modo [String, nil] Modo de la jornada ('P', 'R', 'S')
# @return [Hash] Bitácora creada con ID
# @raise [PG::Error] Si ya existe bitácora para esa fecha
def create_bitacora(fecha:, resumen: nil)
def create_bitacora(fecha:, resumen: nil, modo: 'P')
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
modo ||= 'P'
unless MODOS_VALIDOS.include?(modo)
raise ArgumentError, "Modo inválido. Válidos: #{MODOS_VALIDOS.join(', ')}"
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen)
VALUES ($1, $2)
RETURNING id, fecha, resumen, created_at, updated_at
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen, modo)
VALUES ($1, $2, $3)
RETURNING id, fecha, resumen, modo, created_at, updated_at
SQL
result = @connection.exec_params(query, [fecha.to_s, resumen])
result = @connection.exec_params(query, [fecha.to_s, resumen, modo])
result.first
end
@@ -239,7 +250,7 @@ module BitacorasDB
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, fecha, resumen, created_at, updated_at
SELECT id, fecha, resumen, modo, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
#{where_clause}
ORDER BY fecha DESC
@@ -261,7 +272,7 @@ module BitacorasDB
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
query = <<~SQL
SELECT id, fecha, resumen, created_at, updated_at
SELECT id, fecha, resumen, modo, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
WHERE fecha = $1
SQL
@@ -280,7 +291,7 @@ module BitacorasDB
# @param descripcion [String, nil] Descripción opcional
# @param activo [Boolean] Si el nodo está activo
# @return [Hash] Nodo creado con ID
def create_nodo(nombre:, ip: nil, tipo: 'servidor', descripcion: nil, activo: true)
def create_nodo(nombre:, ip: nil, tipo: 'servidor', descripcion: nil, activo: true, ambito_id: nil)
tipos_validos = ['servidor', 'hipervisor', 'vm', 'pc', 'vmware', 'xenserver', 'servicio']
unless tipos_validos.include?(tipo.downcase)
@@ -288,12 +299,12 @@ module BitacorasDB
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:nodos]} (nombre, ip, tipo, descripcion, activo)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
INSERT INTO #{SCHEMA}.#{TABLES[:nodos]} (nombre, ip, tipo, descripcion, activo, ambito_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, nombre, ip, tipo, descripcion, activo, ambito_id, created_at, updated_at
SQL
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo])
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo, ambito_id])
result.first
end
@@ -303,30 +314,37 @@ module BitacorasDB
# @param tipo [String, nil] Filtrar por tipo
# @param limit [Integer] Límite de resultados
# @return [Array<Hash>] Lista de nodos
def list_nodos(activo: nil, tipo: nil, limit: 100)
def list_nodos(activo: nil, tipo: nil, ambito_id: nil, limit: 100)
conditions = []
params = []
param_index = 1
if !activo.nil?
conditions << "activo = $#{param_index}"
conditions << "n.activo = $#{param_index}"
params << activo
param_index += 1
end
if tipo
conditions << "tipo = $#{param_index}"
conditions << "n.tipo = $#{param_index}"
params << tipo
param_index += 1
end
if ambito_id
conditions << "n.ambito_id = $#{param_index}"
params << ambito_id
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:nodos]}
SELECT n.id, n.nombre, n.ip, n.tipo, n.descripcion, n.activo, n.ambito_id, a.nombre as ambito_nombre, n.created_at, n.updated_at
FROM #{SCHEMA}.#{TABLES[:nodos]} n
LEFT JOIN #{SCHEMA}.ambitos a ON n.ambito_id = a.id
#{where_clause}
ORDER BY nombre
ORDER BY n.nombre
LIMIT $#{param_index}
SQL
@@ -351,6 +369,68 @@ module BitacorasDB
result.first&.to_h
end
# Actualiza un nodo existente
#
# @param nombre [String] Nombre del nodo a actualizar
# @param ip [String, nil] Nueva dirección IP
# @param tipo [String, nil] Nuevo tipo
# @param descripcion [String, nil] Nueva descripción
# @param activo [Boolean, nil] Nuevo estado activo
# @param ambito_id [Integer, nil] Nuevo ámbito ID
# @return [Hash, nil] Nodo actualizado o nil si no existe
def update_nodo(nombre:, ip: nil, tipo: nil, descripcion: nil, activo: nil, ambito_id: nil)
nodo = find_nodo_by_nombre(nombre)
return nil unless nodo
updates = []
params = [nombre]
param_index = 2
if ip
updates << "ip = $#{param_index}"
params << ip
param_index += 1
end
if tipo
updates << "tipo = $#{param_index}"
params << tipo
param_index += 1
end
if descripcion
updates << "descripcion = $#{param_index}"
params << descripcion
param_index += 1
end
if !activo.nil?
updates << "activo = $#{param_index}"
params << activo
param_index += 1
end
if ambito_id
updates << "ambito_id = $#{param_index}"
params << ambito_id
param_index += 1
end
return nodo if updates.empty?
updates << "updated_at = CURRENT_TIMESTAMP"
query = <<~SQL
UPDATE #{SCHEMA}.#{TABLES[:nodos]}
SET #{updates.join(', ')}
WHERE nombre = $1
RETURNING id, nombre, ip, tipo, descripcion, activo, ambito_id, created_at, updated_at
SQL
result = @connection.exec_params(query, params)
result.first&.to_h
end
# OPERACIONES CRUD PARA ENTRADAS (I-F-D-E)
# Crea una nueva entrada cronológica
@@ -377,13 +457,26 @@ module BitacorasDB
estado = fin_time ? '✅' : '⏳'
end
# Inferir Modo
# Inferir Modo (herencia de jornada)
if modo.nil? || modo.strip.empty?
modo_bitacora = 'P'
if bitacora_id
res = @connection.exec_params("SELECT modo FROM #{SCHEMA}.#{TABLES[:entradas]} WHERE bitacora_id = $1 ORDER BY id DESC LIMIT 1", [bitacora_id])
modo = res.ntuples > 0 ? res[0]['modo'] : 'P'
res = @connection.exec_params(
"SELECT modo FROM #{SCHEMA}.#{TABLES[:bitacoras]} WHERE id = $1",
[bitacora_id]
)
modo_bitacora = res.first['modo'] if res.ntuples > 0
end
# Regla: jornada presencial (P) + hora fuera de 8:00-13:00 → remoto (R)
inicio_horario = Time.new(2000, 1, 1, 8, 0, 0)
fin_horario = Time.new(2000, 1, 1, 13, 0, 0)
if modo_bitacora == 'P' && inicio_time >= fin_horario
modo = 'R' # Fuera de horario presencial
else
modo = 'P'
modo = modo_bitacora
end
end
@@ -415,7 +508,7 @@ module BitacorasDB
entrada = result.first
# Registrar evento para el sensor de mejora continua
MejorasSensor.registrar_evento(
::ADN::MejorasSensor.registrar_evento(
subcomando: 'evento:crear',
args: [inicio_time, descripcion, fin_time, estado, modo, es_ia, tema_id, bitacora_id, nodo_id],
nodo: nodo_id ? nil : 'desconocido', # Simplified for this example
@@ -450,7 +543,7 @@ module BitacorasDB
i = 1
params.each do |k, v|
if [:inicio, :fin, :descripcion, :estado, :modo, :es_ia, :nodo_id].include?(k)
if [:inicio, :fin, :descripcion, :estado, :modo, :es_ia, :nodo_id, :ambito_id, :doc_path].include?(k)
val = ([:inicio, :fin].include?(k)) ? parse_time(v) : v
set_clauses << "#{k} = $#{i}"
values << val
@@ -472,7 +565,7 @@ module BitacorasDB
entrada = result.first&.to_h
# Registrar evento para el sensor de mejora continua
MejorasSensor.registrar_evento(
::ADN::MejorasSensor.registrar_evento(
subcomando: 'evento:actualizar',
args: [id] + values, # Simplified for this example
nodo: nil, # In a real implementation, we would extract the nodo_id from the original entrada
@@ -517,7 +610,7 @@ module BitacorasDB
# @param limit [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @return [Array<Hash>] Lista de entradas
def list_entradas(bitacora_id: nil, nodo_id: nil, tema_id: nil, estado: nil,
def list_entradas(bitacora_id: nil, nodo_id: nil, ambito_id: nil, tema_id: nil, estado: nil,
desde: nil, hasta: nil, buscar: nil, limit: 100, offset: 0)
conditions = []
params = []
@@ -535,6 +628,12 @@ module BitacorasDB
param_index += 1
end
if ambito_id
conditions << "e.ambito_id = $#{param_index}"
params << ambito_id
param_index += 1
end
if tema_id
conditions << "e.tema_id = $#{param_index}"
params << tema_id
@@ -569,14 +668,16 @@ module BitacorasDB
query = <<~SQL
SELECT e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
e.tema_id, e.bitacora_id, e.nodo_id, e.ambito_id, e.created_at, e.updated_at,
b.fecha as bitacora_fecha,
n.nombre as nodo_nombre,
t.titulo as tema_titulo
t.titulo as tema_titulo,
a.nombre as ambito_nombre
FROM #{SCHEMA}.#{TABLES[:entradas]} e
LEFT JOIN #{SCHEMA}.#{TABLES[:bitacoras]} b ON e.bitacora_id = b.id
LEFT JOIN #{SCHEMA}.#{TABLES[:nodos]} n ON e.nodo_id = n.id
LEFT JOIN #{SCHEMA}.#{TABLES[:temas]} t ON e.tema_id = t.id
LEFT JOIN #{SCHEMA}.ambitos a ON e.ambito_id = a.id
#{where_clause}
ORDER BY COALESCE(b.fecha, CURRENT_DATE) DESC, e.inicio DESC
LIMIT $#{param_index} OFFSET $#{param_index + 1}
+918
View File
@@ -0,0 +1,918 @@
# frozen_string_literal: true
# adn/tools/db/core/bitacora_db.rb — Clase principal para conexión a PostgreSQL
# ================================================================================
# Esta clase proporciona una interfaz Ruby para interactuar con la base de datos
# dtic-BITACORAs. Maneja conexiones, operaciones CRUD y validaciones para las
# tablas del esquema 'bitacoras':
# • nodos - Servidores, VMs, PCs del sistema
# • bitacoras - Bitácoras diarias (fecha única)
# • temas - Agrupadores de entradas por nodo
# • entradas - Entradas cronológicas en formato I-F-D-E
#
# Uso básico:
# db = BitacorasDB::BitacoraDB.new
# db.connection_test? # Verificar conexión
# bitacoras = db.list_bitacoras(limit: 10)
# db.create_entrada(inicio: '09:00', fin: '10:00', descripcion: 'Reunión', nodo_id: 1)
#
# Uso con bloque (cierre automático):
# BitacorasDB::BitacoraDB.with_connection do |db|
# db.create_bitacora(fecha: Date.today)
# end
#
# Configuración:
# La configuración se carga desde tools/config/database.yml
# Entorno por defecto: development (contenedor Docker en puerto 5433)
require 'pg'
require 'yaml'
require 'json'
require 'date'
require 'time'
require 'connection_pool'
require_relative './mejoras_sensor'
module BitacorasDB
class BitacoraDB
# Constantes para el esquema y tablas
SCHEMA = 'bitacoras'
TABLES = {
nodos: 'nodos',
bitacoras: 'bitacoras',
temas: 'temas',
entradas: 'entradas'
}.freeze
# Estados válidos para entradas y temas
ESTADOS_VALIDOS = ['⏳', '✅', '❌', '⚠️', '⏸️'].freeze
# Modos válidos para entradas
MODOS_VALIDOS = ['P', 'R', 'S'].freeze # P=Presencial, R=Remoto, S=Servicio
# Inicializa la conexión a la base de datos
#
# @param env [Symbol, String] Entorno (:development, :test, :production)
# @param config_path [String] Ruta al archivo de configuración YAML
# @param logger [Object] Instancia de logger (por defecto ADN.logger si disponible)
def initialize(env: :development, config_path: nil, logger: nil)
@env = env.to_s
@config_path = config_path || default_config_path
@config = load_config
@connection_pool = nil
@logger = logger || default_logger
end
# Carga la configuración desde el archivo YAML
#
# @return [Hash] Configuración para el entorno actual
# @raise [RuntimeError] Si no se encuentra la configuración o el entorno
def load_config
unless File.exist?(@config_path)
raise "Archivo de configuración no encontrado: #{@config_path}"
end
config = YAML.load_file(@config_path)
unless config && config[@env]
raise "Entorno '#{@env}' no encontrado en #{@config_path}. Entornos disponibles: #{config.keys.join(', ')}"
end
config[@env]
end
# Obtiene una conexión del pool
#
# @return [PG::Connection] Conexión PostgreSQL del pool
# @raise [PG::Error] Si falla la conexión
def connection
return @connection_pool if @connection_pool
begin
@logger&.info("Creando pool de conexiones a PostgreSQL: #{@config['host']}:#{@config['port']}/#{@config['database']}")
@connection_pool = ConnectionPool.new(size: 5, timeout: 5) do
PG.connect(
host: @config['host'],
port: @config['port'],
dbname: @config['database'],
user: @config['username'],
password: @config['password'],
connect_timeout: @config['timeout'] || 5000
)
end
# Configurar búsqueda de esquema en todas las conexiones del pool
@connection_pool.with do |conn|
if @config['schema_search_path']
conn.exec("SET search_path TO #{@config['schema_search_path']}")
end
end
@logger&.info("Pool de conexiones a PostgreSQL establecido correctamente")
@connection_pool
rescue PG::Error => e
@logger&.error("Error creando pool de conexiones a PostgreSQL: #{e.message}")
raise
end
end
# Cierra todas las conexiones en el pool
def disconnect
if @connection_pool
@connection_pool.shutdown
@connection_pool = nil
@logger&.info("Pool de conexiones a PostgreSQL cerrado")
end
end
# Ejecuta un bloque con una conexión del pool, cerrando automáticamente al final
#
# @yield [BitacoraDB] Instancia de BitacoraDB con conexión establecida
# @return [Object] Resultado del bloque
def with_connection(&block)
# Obtiene una conexión del pool y la devuelve al final
connection.with do |conn|
# Temporarily replace @connection with the pooled connection for the block
original_connection = @connection
@connection = conn
begin
yield self
ensure
@connection = original_connection
end
end
end
# Método de clase para uso con bloque
#
# @param env [Symbol] Entorno a usar
# @param config_path [String] Ruta al archivo de configuración
# @yield [BitacoraDB] Instancia de BitacoraDB
# @return [Object] Resultado del bloque
def self.with_connection(env: :development, config_path: nil, &block)
db = new(env: env, config_path: config_path)
db.with_connection(&block)
end
# Verifica si hay una conexión válida en el pool
#
# @return [Boolean] true si hay una conexión válida en el pool
def connection_test?
connection.with do |conn|
begin
conn.exec('SELECT 1')
true
rescue PG::Error
false
end
end
rescue
false
end
# Obtiene información de salud de la base de datos
#
# @param incluir_sugerencias [Boolean] Si se deben incluir sugerencias de mejora
# @return [Hash] Información de estado y estadísticas
def health_check(incluir_sugerencias: false)
stats = statistics
{
conexion_activa: connection_test?,
version_postgresql: version_postgresql,
estadisticas: stats
}.tap do |resultado|
if incluir_sugerencias && MejorasSensor.activo?
resultado[:sugerencias_mejora] = MejorasSensor.generar_sugerencias
end
end
end
end
# OPERACIONES CRUD PARA BITÁCORAS
# Crea una nueva bitácora diaria
#
# @param fecha [Date, String] Fecha de la bitácora
# @param resumen [String, nil] Resumen opcional
# @return [Hash] Bitácora creada con ID
# @raise [PG::Error] Si ya existe bitácora para esa fecha
def create_bitacora(fecha:, resumen: nil)
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen)
VALUES ($1, $2)
RETURNING id, fecha, resumen, created_at, updated_at
SQL
result = @connection.exec_params(query, [fecha.to_s, resumen])
result.first
end
# Obtiene bitácoras con paginación y filtros
#
# @param limit [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento para paginación
# @param desde [Date, String, nil] Fecha desde
# @param hasta [Date, String, nil] Fecha hasta
# @return [Array<Hash>] Lista de bitácoras
def list_bitacoras(limit: 50, offset: 0, desde: nil, hasta: nil)
conditions = []
params = []
param_index = 1
if desde
conditions << "fecha >= $#{param_index}"
params << Date.parse(desde.to_s).to_s
param_index += 1
end
if hasta
conditions << "fecha <= $#{param_index}"
params << Date.parse(hasta.to_s).to_s
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, fecha, resumen, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
#{where_clause}
ORDER BY fecha DESC
LIMIT $#{param_index} OFFSET $#{param_index + 1}
SQL
params << limit
params << offset
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# Busca una bitácora por fecha
#
# @param fecha [Date, String] Fecha a buscar
# @return [Hash, nil] Bitácora encontrada o nil
def find_bitacora_by_fecha(fecha)
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
query = <<~SQL
SELECT id, fecha, resumen, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
WHERE fecha = $1
SQL
result = @connection.exec_params(query, [fecha.to_s])
result.first&.to_h
end
# OPERACIONES CRUD PARA NODOS
# Crea un nuevo nodo (servidor, VM, PC)
#
# @param nombre [String] Nombre único del nodo
# @param ip [String, nil] Dirección IP
# @param tipo [String] Tipo ('servidor', 'hipervisor', 'vm', 'pc')
# @param descripcion [String, nil] Descripción opcional
# @param activo [Boolean] Si el nodo está activo
# @return [Hash] Nodo creado con ID
def create_nodo(nombre:, ip: nil, tipo: 'servidor', descripcion: nil, activo: true)
tipos_validos = ['servidor', 'hipervisor', 'vm', 'pc', 'vmware', 'xenserver', 'servicio']
unless tipos_validos.include?(tipo.downcase)
raise ArgumentError, "Tipo de nodo inválido. Válidos: #{tipos_validos.join(', ')}"
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:nodos]} (nombre, ip, tipo, descripcion, activo)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
SQL
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo])
result.first
end
# Lista nodos con filtros
#
# @param activo [Boolean, nil] Filtrar por estado activo
# @param tipo [String, nil] Filtrar por tipo
# @param limit [Integer] Límite de resultados
# @return [Array<Hash>] Lista de nodos
def list_nodos(activo: nil, tipo: nil, limit: 100)
conditions = []
params = []
param_index = 1
if !activo.nil?
conditions << "activo = $#{param_index}"
params << activo
param_index += 1
end
if tipo
conditions << "tipo = $#{param_index}"
params << tipo
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:nodos]}
#{where_clause}
ORDER BY nombre
LIMIT $#{param_index}
SQL
params << limit
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# Busca nodo por nombre
#
# @param nombre [String] Nombre del nodo
# @return [Hash, nil] Nodo encontrado o nil
def find_nodo_by_nombre(nombre)
query = <<~SQL
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:nodos]}
WHERE nombre = $1
SQL
result = @connection.exec_params(query, [nombre])
result.first&.to_h
end
# OPERACIONES CRUD PARA ENTRADAS (I-F-D-E)
# Crea una nueva entrada cronológica
#
# @param inicio [String, Time] Hora de inicio (formato HH:MM o Time)
# @param descripcion [String] Descripción de la actividad
# @param fin [String, Time, nil] Hora de fin (opcional)
# @param estado [String] Estado (⏳, ✅, ❌, ⚠️, ⏸️)
# @param modo [String] Modo (P=Presencial, R=Remoto)
# @param es_ia [Boolean] Si fue generado por IA
# @param tema_id [Integer, nil] ID del tema relacionado
# @param bitacora_id [Integer, nil] ID de la bitácora
# @param nodo_id [Integer, nil] ID del nodo
# @return [Hash] Entrada creada con ID
def create_entrada(inicio:, descripcion:, fin: nil, estado: nil, modo: nil, es_ia: false,
tema_id: nil, bitacora_id: nil, nodo_id: nil)
# Validar formato de tiempo
inicio_time = parse_time(inicio)
fin_time = parse_time(fin) if fin
# Inferir Estado
if estado.nil? || estado.strip.empty?
estado = fin_time ? '✅' : '⏳'
end
# Inferir Modo
if modo.nil? || modo.strip.empty?
if bitacora_id
res = @connection.exec_params("SELECT modo FROM #{SCHEMA}.#{TABLES[:entradas]} WHERE bitacora_id = $1 ORDER BY id DESC LIMIT 1", [bitacora_id])
modo = res.ntuples > 0 ? res[0]['modo'] : 'P'
else
modo = 'P'
end
end
# Validar estado
unless ESTADOS_VALIDOS.include?(estado)
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
end
# Validar modo
unless MODOS_VALIDOS.include?(modo)
raise ArgumentError, "Modo inválido. Válidos: #{MODOS_VALIDOS.join(', ')}"
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:entradas]} (
inicio, fin, descripcion, estado, modo, es_ia,
tema_id, bitacora_id, nodo_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, inicio, fin, descripcion, estado, modo, es_ia,
tema_id, bitacora_id, nodo_id, created_at, updated_at
SQL
result = @connection.exec_params(
query,
[inicio_time, fin_time, descripcion, estado, modo, es_ia,
tema_id, bitacora_id, nodo_id]
)
entrada = result.first
# Registrar evento para el sensor de mejora continua
MejorasSensor.registrar_evento(
subcomando: 'evento:crear',
args: [inicio_time, descripcion, fin_time, estado, modo, es_ia, tema_id, bitacora_id, nodo_id],
nodo: nodo_id ? nil : 'desconocido', # Simplificado para este ejemplo
estado_salida: estado,
duracion_ms: 0, # En una implementación real, mediríamos el tiempo
es_ia: es_ia,
contexto: {
conexiones_usadas: 1, # Simplificado
operacion: 'create_entrada'
}
) if @activo # Solo si el sensor está activo
# Ejecutar hooks después de la creación
ejecutar_hooks_entrada(entrada, :crear) if entrada
entrada
end
# Actualiza una entrada existente
def update_entrada(id, params)
# Auto-inferir estado si se está mandando una hora de fin y no se fuerza un estado explícito
if params.key?(:fin) && !params.key?(:estado)
if params[:fin].nil? || params[:fin].to_s.strip.empty?
params[:estado] = '⏳'
else
params[:estado] = '✅'
end
end
set_clauses = []
values = []
i = 1
params.each do |k, v|
if [:inicio, :fin, :descripcion, :estado, :modo, :es_ia, :nodo_id].include?(k)
val = ([:inicio, :fin].include?(k)) ? parse_time(v) : v
set_clauses << "#{k} = $#{i}"
values << val
i += 1
end
end
return nil if set_clauses.empty?
query = <<~SQL
UPDATE #{SCHEMA}.#{TABLES[:entradas]}
SET #{set_clauses.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE id = $#{i}
RETURNING *
SQL
values << id
result = @connection.exec_params(query, values)
entrada = result.first&.to_h
# Registrar evento para el sensor de mejora continua
MejorasSensor.registrar_evento(
subcomando: 'evento:actualizar',
args: [id] + values, # Simplificado para este ejemplo
nodo: nil, # En una implementación real, extraeríamos el nodo_id de la entrada original
estado_salida: entrada&.dig(:estado),
duracion_ms: 0, # En una implementación real, mediríamos el tiempo
es_ia: false, # En una implementación real, lo determinaríamos desde el contexto
contexto: {
conexiones_usadas: 1, # Simplificado
operacion: 'update_entrada',
id_actualizado: id
}
) if @activo && entrada # Solo si el sensor está activo y hay entrada
# Ejecutar hooks después de la actualización
ejecutar_hooks_entrada(entrada, :actualizar) if entrada
entrada
end
# Elimina una entrada existente
# @param id [Integer] ID de la entrada a eliminar
# @return [Boolean] true si se eliminó, false si no existe
def delete_entrada(id)
query = <<~SQL
DELETE FROM #{SCHEMA}.#{TABLES[:entradas]}
WHERE id = $1
RETURNING id
SQL
result = @connection.exec_params(query, [id])
result.ntuples > 0
end
# Lista entradas con filtros avanzados
#
# @param bitacora_id [Integer, nil] Filtrar por bitácora
# @param nodo_id [Integer, nil] Filtrar por nodo
# @param tema_id [Integer, nil] Filtrar por tema
# @param estado [String, nil] Filtrar por estado
# @param desde [Time, String, nil] Fecha/hora desde
# @param hasta [Time, String, nil] Fecha/hora hasta
# @param limit [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @return [Array<Hash>] Lista de entradas
def list_entradas(bitacora_id: nil, nodo_id: nil, tema_id: nil, estado: nil,
desde: nil, hasta: nil, buscar: nil, limit: 100, offset: 0)
conditions = []
params = []
param_index = 1
if bitacora_id
conditions << "e.bitacora_id = $#{param_index}"
params << bitacora_id
param_index += 1
end
if nodo_id
conditions << "e.nodo_id = $#{param_index}"
params << nodo_id
param_index += 1
end
if tema_id
conditions << "e.tema_id = $#{param_index}"
params << tema_id
param_index += 1
end
if estado
conditions << "e.estado = $#{param_index}"
params << estado
param_index += 1
end
if desde
conditions << "b.fecha >= $#{param_index}"
params << Date.parse(desde.to_s).to_s
param_index += 1
end
if hasta
conditions << "b.fecha <= $#{param_index}"
params << Date.parse(hasta.to_s).to_s
param_index += 1
end
if buscar
conditions << "e.descripcion ILIKE $#{param_index}"
params << "%#{buscar}%"
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
b.fecha as bitacora_fecha,
n.nombre as nodo_nombre,
t.titulo as tema_titulo
FROM #{SCHEMA}.#{TABLES[:entradas]} e
LEFT JOIN #{SCHEMA}.#{TABLES[:bitacoras]} b ON e.bitacora_id = b.id
LEFT JOIN #{SCHEMA}.#{TABLES[:nodos]} n ON e.nodo_id = n.id
LEFT JOIN #{SCHEMA}.#{TABLES[:temas]} t ON e.tema_id = t.id
#{where_clause}
ORDER BY COALESCE(b.fecha, CURRENT_DATE) DESC, e.inicio DESC
LIMIT $#{param_index} OFFSET $#{param_index + 1}
SQL
params << limit
params << offset
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# OPERACIONES CRUD PARA TEMAS
# Crea un nuevo tema (agrupador de entradas)
#
# @param id_tema [String] Identificador único del tema
# @param titulo [String] Título del tema
# @param estado [String] Estado actual
# @param descripcion [String, nil] Descripción opcional
# @param bitacora_id [Integer, nil] ID de la bitácora relacionada
# @param nodo_id [Integer, nil] ID del nodo relacionado
# @return [Hash] Tema creado con ID
def create_tema(id_tema:, titulo:, estado: '⏳', descripcion: nil,
bitacora_id: nil, nodo_id: nil)
unless ESTADOS_VALIDOS.include?(estado)
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:temas]} (
id_tema, titulo, estado, descripcion, bitacora_id, nodo_id
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, id_tema, titulo, estado, descripcion,
bitacora_id, nodo_id, created_at, updated_at
SQL
result = @connection.exec_params(
query,
[id_tema, titulo, estado, descripcion, bitacora_id, nodo_id]
)
result.first
end
# Lista temas con filtros
#
# @param bitacora_id [Integer, nil] Filtrar por bitácora
# @param nodo_id [Integer, nil] Filtrar por nodo
# @param estado [String, nil] Filtrar por estado
# @param limit [Integer] Límite de resultados
# @return [Array<Hash>] Lista de temas
def list_temas(bitacora_id: nil, nodo_id: nil, estado: nil, limit: 100)
conditions = []
params = []
param_index = 1
if bitacora_id
conditions << "bitacora_id = $#{param_index}"
params << bitacora_id
param_index += 1
end
if nodo_id
conditions << "nodo_id = $#{param_index}"
params << nodo_id
param_index += 1
end
if estado
conditions << "estado = $#{param_index}"
params << estado
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, id_tema, titulo, estado, descripcion,
bitacora_id, nodo_id, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:temas]}
#{where_clause}
ORDER BY id_tema
LIMIT $#{param_index}
SQL
params << limit
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# MÉTODOS DE UTILIDAD
# Obtiene estadísticas del sistema
#
# @return [Hash] Estadísticas de todas las tablas
def statistics
return {} unless connection_test?
stats = {}
TABLES.each_key do |table|
query = "SELECT COUNT(*) as total FROM #{SCHEMA}.#{TABLES[table]}"
result = @connection.exec(query)
stats[table] = result.first['total'].to_i
end
# Estadísticas adicionales
query = <<~SQL
SELECT
COUNT(DISTINCT nodo_id) as nodos_con_entradas,
COUNT(DISTINCT DATE(created_at)) as dias_con_actividad,
COUNT(CASE WHEN es_ia THEN 1 END) as entradas_ia,
COUNT(CASE WHEN estado = '✅' THEN 1 END) as entradas_completadas
FROM #{SCHEMA}.#{TABLES[:entradas]}
SQL
result = @connection.exec(query)
extra_stats = result.first
stats.merge(
nodos_con_entradas: extra_stats['nodos_con_entradas'].to_i,
dias_con_actividad: extra_stats['dias_con_actividad'].to_i,
entradas_ia: extra_stats['entradas_ia'].to_i,
entradas_completadas: extra_stats['entradas_completadas'].to_i
)
end
# Ejecuta una consulta SQL personalizada
#
# @param sql [String] Consulta SQL
# @param params [Array] Parámetros para la consulta preparada
# @return [PG::Result] Resultado de la consulta
def execute(sql, params = [])
@connection.exec_params(sql, params)
end
# Verifica si existe una bitácora para hoy
#
# @return [Boolean] true si existe bitácora para hoy
def bitacora_hoy_existe?
fecha_hoy = Date.today.to_s
query = "SELECT 1 FROM #{SCHEMA}.#{TABLES[:bitacoras]} WHERE fecha = $1 LIMIT 1"
result = @connection.exec_params(query, [fecha_hoy])
!result.ntuples.zero?
end
# Crea bitácora para hoy si no existe
#
# @param resumen [String, nil] Resumen opcional
# @return [Hash, nil] Bitácora creada o nil si ya existía
def crear_bitacora_hoy(resumen: nil)
return nil if bitacora_hoy_existe?
create_bitacora(fecha: Date.today, resumen: resumen)
end
# ==========================================
# PROYECTOS, FASES E HITOS (Planes Sistemáticos)
# ==========================================
def find_proyecto_by_codigo(codigo)
q = "SELECT * FROM bitacoras.proyectos WHERE codigo = $1"
res = execute(q, [codigo.upcase])
return nil if res.ntuples.zero?
res.first.to_h
end
def list_hitos(proyecto_codigo)
proyecto = find_proyecto_by_codigo(proyecto_codigo)
raise StandardError, "Proyecto no encontrado: #{proyecto_codigo}" unless proyecto
q = <<~SQL
SELECT f.numero as fase_num, f.nombre as fase_nombre,
h.id, h.id_hito, h.titulo, h.estado, h.horas_presencial, h.horas_remoto
FROM bitacoras.fases f
JOIN bitacoras.hitos h ON h.fase_id = f.id
WHERE f.proyecto_id = $1
ORDER BY f.numero, h.id_hito
SQL
res = execute(q, [proyecto['id']])
res.map(&:to_h)
end
def rename_hito_id(proyecto_codigo, id_actual, id_nuevo)
q_busqueda = <<~SQL
SELECT h.id, h.titulo
FROM bitacoras.hitos h
JOIN bitacoras.fases f ON h.fase_id = f.id
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
WHERE p.codigo = $1 AND h.id_hito = $2
SQL
res = execute(q_busqueda, [proyecto_codigo.upcase, id_actual])
raise StandardError, "Hito #{id_actual} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
hito = res.first
q_update = "UPDATE bitacoras.hitos SET id_hito = $1 WHERE id = $2 RETURNING titulo"
res_update = execute(q_update, [id_nuevo, hito['id']])
res_update.first
end
def update_hito(proyecto_codigo, id_hito, estado: nil, horas_presencial: nil, horas_remoto: nil, descripcion: nil)
q_busqueda = <<~SQL
SELECT h.id, h.horas_presencial, h.horas_remoto, h.estado, h.descripcion
FROM bitacoras.hitos h
JOIN bitacoras.fases f ON h.fase_id = f.id
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
WHERE p.codigo = $1 AND h.id_hito = $2
SQL
res = execute(q_busqueda, [proyecto_codigo.upcase, id_hito])
raise StandardError, "Hito #{id_hito} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
hito = res.first
updates = []
params = []
param_count = 1
if estado
updates << "estado = $#{param_count}"
params << estado
param_count += 1
end
unless horas_presencial.nil?
updates << "horas_presencial = $#{param_count}"
params << horas_presencial
param_count += 1
end
unless horas_remoto.nil?
updates << "horas_remoto = $#{param_count}"
params << horas_remoto
param_count += 1
end
if descripcion
updates << "descripcion = $#{param_count}"
params << descripcion
param_count += 1
end
return hito if updates.empty?
updates << "updated_at = NOW()"
params << hito['id']
query = "UPDATE bitacoras.hitos SET #{updates.join(', ')} WHERE id = $#{param_count} RETURNING *"
resultado = execute(query, params)
resultado.first.to_h
end
private
# Ruta por defecto al archivo de configuración
def default_config_path
File.expand_path('../../config/database.yml', __dir__)
end
# Logger por defecto (ADN.logger si está disponible)
def default_logger
# Intentar usar ADN.logger si el módulo ADN está cargado
if defined?(ADN) && ADN.respond_to?(:logger)
ADN.logger
else
# Logger simple como fallback
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
def warn(msg); puts "[WARN] #{msg}"; end
end.new
end
end
# Parsea un tiempo a formato HH:MM:SS
#
# @param time [String, Time, nil] Tiempo a parsear
# @return [String, nil] Tiempo en formato HH:MM:SS o nil
def parse_time(time)
return nil if time.nil?
case time
when Time
time.strftime('%H:%M:%S')
when String
# Intentar parsear varios formatos
if time.match?(/^\d{1,2}:\d{2}$/) # HH:MM
"#{time}:00"
elsif time.match?(/^\d{1,2}:\d{2}:\d{2}$/) # HH:MM:SS
time
else
raise ArgumentError, "Formato de tiempo inválido: #{time}. Use HH:MM o HH:MM:SS"
end
else
raise ArgumentError, "Tiempo debe ser String o Time, recibido: #{time.class}"
end
end
# Convierte un resultado PG a Hash
def result_to_hash(result)
return {} if result.ntuples.zero?
result.first.to_h
end
# Ejecuta hooks personalizados después de operaciones de entrada
# Esto implementa el concepto de DB Hooks para migrar triggers.yml a capa de datos
#
# @param entrada [Hash] La entrada que fue creada o actualizada
# @param accion [Symbol] :crear o :actualizar
def ejecutar_hooks_entrada(entrada, accion)
# Hook para proyectos con etiqueta [P2604]
if entrada[:descripcion]&.match?(/\[P2604\]/)
proyecto_codigo = entrada[:descripcion].match(/\[P2604\.(\d+)\.(\d+)\.(\d+)\]/)&.to_s
if proyecto_codigo
# Normalizar formato: P2604.05.01 -> P2604
proyecto_base = proyecto_codigo.split('.')[0..1].join('.')
# Actualizar el manifiesto del proyecto en la base de datos
proyecto = find_proyecto_by_codigo(proyecto_base)
if proyecto
# Aquí podríamos actualizar algún campo del proyecto basado en la entrada
# Por ahora, solo registramos que se debería actualizar
@logger&.info("Hook ejecutado: Entrada #{accion} para proyecto #{proyecto_base} detectada")
# En una implementación completa, aquí actualizaríamos el manifiesto del proyecto
end
end
end
# Otros hooks podrían ir aquí...
# Por ejemplo, hooks para sincronización con nodos, etc.
end
end
end
+227
View File
@@ -0,0 +1,227 @@
# frozen_string_literal: true
# adn/tools/db/core/mejoras_sensor.rb — Sensor de mejora continua para el ecosistema ADN
# ======================================================================================
# Este módulo implementa un sensor ligero que recopila métricas de uso del sistema
# y genera sugerencias de mejora basadas en patrones detectados.
#
# Diseñado para seguir los principios ADN:
# - DB-First: Todas las sugerencias se registran como entradas en la bitácora
# - Menos es Más: Lógica simple, reglas transparentes, sobrecarga mínima
# - Armonía Integral: Nada se aplica automáticamente; requiere intervención humana trazable
#
# Uso interno:
# MejorasSensor.registrar_evento(...) # Llamado desde operaciones clave
# MejorasSensor.generar_sugerencias() # Genera entradas ⏳ en bitácora
require 'digest/md5'
require_relative 'bitacora_db'
module ADN
module MejorasSensor
# Configuración del sensor
@activo = true # Desactivado por defecto para minimizar overhead
@umbrales = {
conexiones_por_minuto: 5, # Umbral para sugerir connection pooling
comandos_repetidos: 5, # Umbral para sugerir automatización
ventana_tiempo: 300 # Ventana de análisis en segundos (5 min)
}
# Activar/desactivar el sensor
# @param activo [Boolean] Estado de activación
def self.activo=(activo)
@activo = activo
end
# @return [Boolean] Estado actual de activación
def self.activo?
@activo
end
# Registrar un evento de uso para análisis posterior
# @param subcomando [String] Subcomando ejecutado (ej: 'evento:crear')
# @param args [Array] Argumentos del subcomando
# @param nodo [String, nil] Nodo involucrado (si aplica)
# @param estado_salida [String] Estado de salida (✅, ❌, ⏳)
# @param duracion_ms [Integer] Duración de ejecución en milisegundos
# @param es_ia [Boolean] Si fue ejecutado por IA
# @param contexto [Hash] Datos adicionales para análisis
def self.registrar_evento(subcomando:, args: [], nodo: nil, estado_salida: nil,
duracion_ms: 0, es_ia: false, contexto: {})
return unless @activo # No hacer nada si el sensor está desactivado
# Generar hash de argumentos para agrupat patrones similares
args_str = args.map(&:to_s).join('|')
args_hash = Digest::MD5.hexdigest(args_str)[0, 8] # Primeros 8 caracteres
# Preparar contexto para almacenamiento
contexto_json = contexto.to_json
# Almacenar métrica en la base de datos
BitacorasDB::BitacoraDB.with_connection do |db|
db.execute <<~SQL, [subcomando, args_hash, nodo, estado_salida, duracion_ms, es_ia, contexto_json]
INSERT INTO bitacoras.mejoras_metricas (
subcomando, args_hash, nodo, estado_salida, duracion_ms, es_ia, contexto
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
SQL
end
end
# Generar sugerencias de mejora basadas en las métricas recopiladas
# @return [Array<Hash>] Lista de sugerencias generadas
def self.generar_sugerencias
sugerencias = []
BitacorasDB::BitacoraDB.with_connection do |db|
# 1. Detectar fricción de conexiones DB excesivas
sugerencias_conexiones = _detectar_friccion_conexiones(db)
sugerencias.concat(sugerencias_conexiones)
# 2. Detectar comandos repetitivos que podrían automatizarse
sugerencias_automatizacion = _detectar_comandos_repetitivos(db)
sugerencias.concat(sugerencias_automatizacion)
# 3. Detectar inconsistencias documento-DB (ej: entradas [P2604] sin actualizaciones)
sugerencias_inconsistencia = _detectar_inconsistencias_documento_db(db)
sugerencias.concat(sugerencias_inconsistencia)
end
sugerencias
end
# Aplicar una sugerencia específica (marcar como implementada)
# @param sugerencia_id [Integer] ID de la entrada de bitácora a actualizar
# @return [Boolean] True si se actualizó exitosamente
def self.aplicar_sugerencia(sugerencia_id)
BitacorasDB::BitacoraDB.with_connection do |db|
resultado = db.update_entrada(sugerencia_id, { estado: '✅' })
resultado.present?
end
end
private
# Detectar fricción de conexiones DB excesivas
# @param db [BitacoraDB] Instancia de conexión a la base de datos
# @return [Array<Hash>] Sugerencias relacionadas con conexiones DB
def self._detectar_friccion_conexiones(db)
sugerencias = []
ventana = Time.now - @umbrales[:ventana_tiempo]
# Consultar promedio de conexiones usadas por minuto en la ventana de tiempo
resultado = db.execute(<<~SQL, [ventana])
SELECT
AVG((contexto->>'conexiones_usadas')::int) as avg_conexiones,
COUNT(*) as total_muestras,
SUBSTRING(MAX(momento)::text, 1, 19) as ultima_medicion
FROM bitacoras.mejoras_metricas
WHERE momento > $1
AND contexto ? 'conexiones_usadas'
SQL
fila = resultado.first
avg_conexiones = (fila['avg_conexiones'] || 0).to_f
total_muestras = fila['total_muestras'].to_i
if total_muestras >= 3 && avg_conexiones > @umbrales[:conexiones_por_minuto]
sugerencias << {
tipo: :conexiones_excessivas,
descripcion: "Detectadas #{avg_conexiones.round(1)} conexiones DB promedio por minuto en las últimas #{@umbrales[:ventana_tiempo]/60} minutos. Umbral: #{@umbrales[:conexiones_por_minuto]}",
accion_sugerida: "Implementar connection pooling en BitacoraDB (similar a lo hecho en #1202)",
prioridad: :alta,
datos: {
avg_conexiones: avg_conexiones,
total_muestras: total_muestras,
umbral: @umbrales[:conexiones_por_minuto],
ventana_minutos: @umbrales[:ventana_tiempo]/60
}
}
end
sugerencias
end
# Detectar comandos repetitivos que podrían beneficiarse de automatización
# @param db [BitacoraDB] Instancia de conexión a la base de datos
# @return [Array<Hash>] Sugerencias relacionadas con automatización
def self._detectar_comandos_repetitivos(db)
sugerencias = []
ventana = Time.now - @umbrales[:ventana_tiempo]
# Encontrar subcomandos con argumentos similares que se repiten frecuentemente
resultado = db.execute(<<~SQL, [ventana, @umbrales[:comandos_repetitivos]])
SELECT
subcomando,
args_hash,
COUNT(*) as frecuencia,
MAX(momento)::timestamp as ultima_ejecucion,
BOOL_AND(es_ia) as todas_ia
FROM bitacoras.mejoras_metricas
WHERE momento > $1
GROUP BY subcomando, args_hash
HAVING COUNT(*) >= $2
ORDER BY frecuencia DESC
LIMIT 10
SQL
resultado.each do |fila|
subcomando = fila['subcomando']
frecuencia = fila['frecuencia'].to_i
es_ia = fila['todas_ia'] == 't' || fila['todas_ia'] == true
sugerencias << {
tipo: :comando_repetitivo,
descripcion: "Subcomando '#{subcomando}' ejecutado #{frecuencia} veces en las últimas #{@umbrales[:ventana_tiempo]/60} minutos con argumentos similares",
accion_sugerida: "Considerar crear hook automático o plantilla para este patrón de uso",
prioridad: es_ia ? :alta : :media, # Mayor prioridad si es usado por IA
datos: {
subcomando: subcomando,
frecuencia: frecuencia,
ventana_minutos: @umbrales[:ventana_tiempo]/60,
es_ia: es_ia
}
}
end
sugerencias
end
# Detectar inconsistencias documento-DB (ej: entradas [P2604] sin actualizaciones en proyectos)
# @param db [BitacoraDB] Instancia de conexión a la base de datos
# @return [Array<Hash>] Sugerencias relacionadas con inconsistencias
def self._detectar_inconsistencias_documento_db(db)
sugerencias = []
ventana = Time.now - @umbrales[:ventana_tiempo]
# Buscar entradas con etiquetas de proyectos que podrían necesitar actualización de manifiesto
resultado = db.execute(<<~SQL, [ventana])
SELECT
COUNT(*) as count_entradas,
SUBSTRING(MAX(e.created_at)::text, 1, 19) as ultima_entrada
FROM bitacoras.entradas e
JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
WHERE b.fecha > $1::date
AND e.descripcion ~* 'P2604\.'
SQL
fila = resultado.first
count_entradas = fila['count_entradas'].to_i
if count_entradas > 0
sugerencias << {
tipo: :inconsistencia_documento_db,
descripcion: "Detectadas #{count_entradas} entradas con etiquetas [P2604.*] en las últimas #{@umbrales[:ventana_tiempo]/60} minutos que podrían requerir actualización de manifiesto de proyecto",
accion_sugerida: "Verificar si se necesita actualizar documentos en docs/proy/P2604/... o mejorar DB Hooks",
prioridad: :media,
datos: {
count_entradas: count_entradas,
ventana_minutos: @umbrales[:ventana_tiempo]/60
}
}
end
sugerencias
end
end
end