Refactor(ADN): Migración de tools/adn a adn/tools en busca de la Armonía Integral del DIIAA

This commit is contained in:
Ricardo Monla
2026-03-10 22:25:01 -03:00
parent 174ef7d53c
commit 78006032eb
69 changed files with 432 additions and 671 deletions
+639
View File
@@ -0,0 +1,639 @@
# frozen_string_literal: true
# adn/tools/db/importers/md_importer.rb — Importador de bitácoras .md a PostgreSQL
# ================================================================================
# Esta clase convierte bitácoras en formato Markdown al esquema de base de datos
# dtic-BITACORAs. Procesa la estructura jerárquica:
# Archivo .md → Bitácora → Nodos → Temas → Entradas (I-F-D-E)
#
# Formato de entrada (.md):
# # Bitácora 2026-02-28
# ## Control de Gestión
# ### 📝 Resumen de Actividades
# | NODO | RESUMEN INTEGRAL |
# | :--- | :--- |
# | srv-ns8 | Resumen del día... |
#
# ## 📂 Actividades Detalladas
# ### srv-ns8
# #### ✅ - SEC01 - Refactorización ns8-candados
# | I | F | Descripción | E |
# | :--- | :--- | :--- | :--- |
# | 08:00 | 08:34 | Refactorización ns8-candados... | [P] ✅ |
#
# Uso:
# importer = MDImporter.new(db_connection)
# resultado = importer.importar_bitacora('bitacoras/2026-02-28.md')
# resultado = importer.importar_todo_directorio('bitacoras/')
#
# Dependencias: Clase BitacoraDB del módulo BitacorasDB
require 'date'
require 'time'
require 'yaml'
require 'digest'
module BitacorasDB
module Importers
class MDImporter
# Estados posibles en archivos .md y su mapeo a BD
ESTADOS_MD = {
'✅' => '✅',
'❌' => '❌',
'⚠️' => '⚠️',
'⏸️' => '⏸️',
'⏳' => '⏳',
'👁️' => '⏳', # Sincronización mapea a ⏳
'➡️' => '⏳', # Tarea continúa en otro día
'🎭' => '✅' # Evento/Servicio
}.freeze
# Modos posibles en archivos .md
MODOS_MD = {
'[P]' => 'P', # Presencial
'[R]' => 'R', # Remoto
'[P/R]' => 'P', # Presencial/Remoto -> Presencial por defecto
'[S]' => 'S' # Servicio (No nodo técnico)
}.freeze
# Tipos de nodo inferidos del nombre
TIPOS_NODO = {
/^srv-/ => 'servidor',
/^dc-/ => 'vm',
/^sql-/ => 'vm',
/^pcv-/ => 'vm',
/^pc-/ => 'pc',
/^srvv-/ => 'vm',
/^dtic-/ => 'servicio',
/-pmox\d+$/ => 'hipervisor',
/-xen\d+$/ => 'hipervisor'
}.freeze
# Inicializa el importador con conexión a base de datos
#
# @param db [BitacoraDB] Instancia de BitacoraDB para operaciones
# @param logger [Object] Logger para registro (opcional)
def initialize(db, logger = nil)
@db = db
@logger = logger || default_logger
@estadisticas = {
archivos_procesados: 0,
bitacoras_creadas: 0,
nodos_creados: 0,
temas_creados: 0,
entradas_creadas: 0,
errores: []
}
end
# Importa una bitácora específica desde archivo .md
#
# @param ruta_md [String] Ruta al archivo .md
# @param opciones [Hash] Opciones de importación
# :sobrescribir [Boolean] Sobrescribir bitácora si ya existe (default: false)
# :crear_nodos [Boolean] Crear nodos automáticamente si no existen (default: true)
# @return [Hash] Resultado de la importación con estadísticas
def importar_bitacora(ruta_md, opciones = {})
defaults = { sobrescribir: false, crear_nodos: true }
opciones = defaults.merge(opciones)
@estadisticas[:archivos_procesados] += 1
begin
# Leer y parsear archivo
contenido = File.read(ruta_md, encoding: 'UTF-8')
fecha = extraer_fecha_desde_ruta(ruta_md) || extraer_fecha_desde_contenido(contenido)
if fecha.nil?
error = "No se pudo determinar fecha del archivo: #{ruta_md}"
@logger.error(error)
@estadisticas[:errores] << error
return { error: error }
end
@logger.info("Importando bitácora #{fecha} desde #{ruta_md}")
# Verificar si ya existe bitácora
bitacora_existente = @db.find_bitacora_by_fecha(fecha)
if bitacora_existente && !opciones[:sobrescribir]
@logger.advertencia("Bitácora #{fecha} ya existe, omitiendo (use --sobrescribir)")
return {
skip: true,
mensaje: "Bitácora ya existe",
bitacora_id: bitacora_existente['id'],
estadisticas: @estadisticas
}
end
# Parsear contenido
estructura = parsear_contenido(contenido, fecha)
# Importar a base de datos
resultado = importar_estructura(estructura, opciones)
resultado.merge(
exitoso: resultado[:errores].empty?,
fecha: fecha,
estadisticas: @estadisticas.dup
)
rescue => e
error = "Error importando #{ruta_md}: #{e.message}"
@logger.error(error)
@estadisticas[:errores] << error
{
error: error,
backtrace: e.backtrace,
estadisticas: @estadisticas
}
end
end
# Importa todas las bitácoras de un directorio
#
# @param directorio [String] Ruta al directorio con archivos .md
# @param opciones [Hash] Opciones de importación
# @return [Hash] Resultado consolidado
def importar_directorio(directorio, opciones = {})
archivos = Dir.glob(File.join(directorio, '*.md')).sort
resultados = []
@logger.info("Importando #{archivos.length} archivos desde #{directorio}")
archivos.each do |archivo|
@logger.info("Procesando: #{File.basename(archivo)}")
resultado = importar_bitacora(archivo, opciones)
resultados << resultado.merge(archivo: archivo)
end
consolidar_resultados(resultados)
end
# Obtiene estadísticas de importación
#
# @return [Hash] Estadísticas acumuladas
def estadisticas
@estadisticas
end
private
# Extrae fecha desde la ruta del archivo (ej: bitacoras/2026-02-28.md)
def extraer_fecha_desde_ruta(ruta)
nombre = File.basename(ruta, '.md')
begin
Date.parse(nombre)
rescue Date::Error
nil
end
end
# Extrae fecha desde el contenido del archivo
def extraer_fecha_desde_contenido(contenido)
# Buscar patrón: # Bitácora 2026-02-28
match = contenido.match(/# Bitácora (\d{4}-\d{2}-\d{2})/)
return Date.parse(match[1]) if match
# Buscar patrón: # Bitácora de Operaciones - 28/02/2026
match = contenido.match(/# Bitácora de Operaciones - (\d{2}\/\d{2}\/\d{4})/)
if match
fecha_str = match[1]
dia, mes, anio = fecha_str.split('/')
return Date.parse("#{anio}-#{mes}-#{dia}")
end
nil
end
# Parsea el contenido del archivo .md a estructura jerárquica
def parsear_contenido(contenido, fecha)
lineas = contenido.lines.map(&:chomp)
estructura = {
fecha: fecha,
resumen: extraer_resumen(contenido),
nodos: {},
secciones: {}
}
nodo_actual = nil
tema_actual = nil
en_actividades_detalladas = false
en_tabla_entradas = false
encabezados_tabla = []
entradas_tabla = []
lineas.each_with_index do |linea, idx|
# Detectar sección de actividades detalladas (con cualquier emoji o sin él)
if linea.match?(/^## .*Actividades Detalladas/)
en_actividades_detalladas = true
next
end
# Detectar fin de sección (siguiente ##)
if en_actividades_detalladas && linea.start_with?('## ') && !linea.match?(/.*Actividades Detalladas/)
en_actividades_detalladas = false
end
# Solo procesar dentro de actividades detalladas
next unless en_actividades_detalladas
# Detectar nodo (### srv-ns8)
if linea.start_with?('### ') && !linea.start_with?('####')
nodo_nombre = linea.sub('### ', '').strip
nodo_actual = nodo_nombre
estructura[:nodos][nodo_actual] ||= {
nombre: nodo_nombre,
temas: {}
}
next
end
# Detectar tema (#### ✅ - SEC01 - Refactorización ns8-candados)
if linea.start_with?('#### ')
tema_linea = linea.sub('#### ', '').strip
tema_info = parsear_tema(tema_linea)
if tema_info && nodo_actual
tema_actual = tema_info[:id_tema]
estructura[:nodos][nodo_actual][:temas][tema_actual] = {
id_tema: tema_info[:id_tema],
titulo: tema_info[:titulo],
estado: tema_info[:estado],
descripcion: tema_info[:descripcion],
entradas: []
}
end
next
end
# Detectar inicio de tabla de entradas
if linea.match?(/^\| I \| F \| Descripción \| (?:J \| )?E \|$/)
en_tabla_entradas = true
encabezados_tabla = linea.split('|').map(&:strip).reject(&:empty?)
entradas_tabla = []
next
end
# Detectar fin de tabla
if en_tabla_entradas && (linea.empty? || linea.start_with?('#') || linea.start_with?('####'))
# Procesar entradas acumuladas
if tema_actual && nodo_actual && !entradas_tabla.empty?
entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla)
# Verificar que exista el tema antes de asignar entradas
if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual]
estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas
else
@logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas")
end
end
en_tabla_entradas = false
encabezados_tabla = []
entradas_tabla = []
end
# Acumular líneas de tabla
if en_tabla_entradas && linea.start_with?('|')
entradas_tabla << linea
end
end
# Procesar última tabla si estaba activa al final del archivo
if en_tabla_entradas && tema_actual && nodo_actual && !entradas_tabla.empty?
entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla)
# Verificar que exista el tema antes de asignar entradas
if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual]
estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas
else
@logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas")
end
end
estructura
end
# Extrae resumen general del contenido
def extraer_resumen(contenido)
# Buscar en sección "Resumen de Actividades" con cualquier emoji o sin él
patron_resumen = /### .*Resumen de Actividades/
match_resumen = contenido.match(patron_resumen)
if match_resumen
seccion = contenido.split(match_resumen[0])[1]
if seccion
# Tomar las primeras líneas después de la tabla
lineas = seccion.lines.map(&:chomp)
resumen_lineas = []
en_tabla = false
lineas.each do |linea|
if linea.match?(/^\|.*\|$/)
en_tabla = true
elsif en_tabla && !linea.match?(/^\|.*\|$/)
# Fin de tabla, empezar a capturar resumen
if linea.strip.length > 10 # Ignorar líneas muy cortas
resumen_lineas << linea.strip
end
end
end
return resumen_lineas.join(' ') if resumen_lineas.any?
end
end
nil
end
# Parsea línea de tema (ej: "✅ - SEC01 - Refactorización ns8-candados")
def parsear_tema(linea)
# Intentar formato estándar: estado - id - título (ej: ✅ - ADN01 - Título)
match_std = linea.match(/^([^\s-]+)\s*-\s*([A-Z0-9.]+)\s*-\s*(.+)$/)
if match_std
return {
id_tema: match_std[2],
titulo: match_std[3].strip,
estado: ESTADOS_MD[match_std[1]] || '⏳',
descripcion: match_std[3].strip
}
end
# Intentar formato simplificado: estado - título (ej: 🎭 - Servicio Auditorio)
match_simple = linea.match(/^([^\s-]+)\s*-\s*(.+)$/)
if match_simple
titulo = match_simple[2].strip
# Generar un ID determinista basado en el título para estabilidad
id_gen = "GEN" + Digest::MD5.hexdigest(titulo)[0..5].upcase
return {
id_tema: id_gen,
titulo: titulo,
estado: ESTADOS_MD[match_simple[1]] || '⏳',
descripcion: titulo
}
end
nil
end
# Parsea líneas de tabla de entradas
def parsear_entradas_tabla(lineas_tabla, encabezados)
entradas = []
# Mapeo de columnas basado en encabezados
columnas = {}
encabezados.each_with_index do |encabezado, idx|
case encabezado.downcase
when 'i' then columnas[:inicio] = idx
when 'f' then columnas[:fin] = idx
when 'descripción' then columnas[:descripcion] = idx
when 'j' then columnas[:jornada] = idx
when 'e' then columnas[:estado] = idx
end
end
lineas_tabla.each do |linea|
# Saltar separadores de tabla Markdown (ej: | :--- | :--- | :--- | :--- |)
next if linea.match?(/^[\|\s:-]+$/) # Solo pipes, espacios, guiones y dos puntos
next if linea.match?(/^\|?\s*:?-+:?\s*\|/) # Separadores con :---, ---, :-:, etc.
# Limpiar pipes iniciales y finales antes de split
linea_limpia = linea.strip.gsub(/^\||\|$/, '')
celdas = linea_limpia.split('|').map(&:strip)
next if celdas.length < 3 # Mínimo: inicio, descripción, estado
# Validar que la celda de inicio tenga contenido
valor_inicio = celdas[columnas[:inicio]]
next if valor_inicio.nil? || valor_inicio.empty? || valor_inicio.match?(/^:?-+:?$/)
fin_valor = nil
if columnas[:fin] && !celdas[columnas[:fin]].nil?
raw_fin = celdas[columnas[:fin]].strip
fin_valor = raw_fin if !raw_fin.empty? && raw_fin != '-'
end
entrada = {
inicio: valor_inicio.gsub(/\s*\(.*?\)\s*/, '').strip,
fin: fin_valor ? fin_valor.gsub(/\s*\(.*?\)\s*/, '').strip : nil,
descripcion: celdas[columnas[:descripcion]],
estado_modo: celdas[columnas[:estado]]
}
# Parsear estado y modo
if columnas[:jornada] && columnas[:estado]
# Nuevo formato: J y E separados
# Unimos temporalmente para usar parsear_estado_modo existente
campo_combinado = "[#{celdas[columnas[:jornada]]}] #{celdas[columnas[:estado]]}"
estado_modo = parsear_estado_modo(campo_combinado)
entrada[:estado] = estado_modo[:estado]
entrada[:modo] = estado_modo[:modo]
elsif columnas[:estado]
# Formato viejo o solo E
estado_modo = parsear_estado_modo(celdas[columnas[:estado]])
entrada[:estado] = estado_modo[:estado]
entrada[:modo] = estado_modo[:modo]
end
entradas << entrada
end
entradas
end
# Parsea campo estado/modo (ej: "[P] ✅")
def parsear_estado_modo(campo)
estado = '⏳'
modo = 'P'
# Buscar estado en el campo
ESTADOS_MD.each do |md_estado, bd_estado|
if campo.include?(md_estado)
estado = bd_estado
break
end
end
# Buscar modo en el campo
MODOS_MD.each do |md_modo, bd_modo|
if campo.include?(md_modo)
modo = bd_modo
break
end
end
{ estado: estado, modo: modo }
end
# Importa estructura parseada a base de datos
def importar_estructura(estructura, opciones)
errores = []
begin
# 1. Crear o actualizar bitácora
bitacora = @db.find_bitacora_by_fecha(estructura[:fecha])
if bitacora
@logger.info("Bitácora #{estructura[:fecha]} ya existe (ID: #{bitacora['id']})")
else
bitacora = @db.create_bitacora(
fecha: estructura[:fecha],
resumen: estructura[:resumen]
)
@estadisticas[:bitacoras_creadas] += 1
@logger.info("Bitácora creada: #{estructura[:fecha]} (ID: #{bitacora['id']})")
end
bitacora_id = bitacora['id']
# 1b. Si se solicita sobrescribir, limpiar entradas previas de esta bitácora
if opciones[:sobrescribir]
@logger.info("Limpiando datos previos de la bitácora #{estructura[:fecha]}...")
# Borrar entradas primero (FK)
@db.execute("DELETE FROM bitacoras.entradas WHERE bitacora_id = $1", [bitacora_id])
# Borrar temas (FK)
@db.execute("DELETE FROM bitacoras.temas WHERE bitacora_id = $1", [bitacora_id])
@estadisticas[:datos_limpiados] = true
end
# 2. Procesar cada nodo
estructura[:nodos].each do |nombre_nodo, datos_nodo|
# Buscar o crear nodo
nodo = @db.find_nodo_by_nombre(nombre_nodo)
if nodo.nil? && opciones[:crear_nodos]
tipo = inferir_tipo_nodo(nombre_nodo)
nodo = @db.create_nodo(
nombre: nombre_nodo,
tipo: tipo,
descripcion: "Importado desde bitácora #{estructura[:fecha]}"
)
@estadisticas[:nodos_creados] += 1
@logger.info("Nodo creado: #{nombre_nodo} (ID: #{nodo['id']})")
elsif nodo.nil?
@logger.advertencia("Nodo #{nombre_nodo} no existe y crear_nodos=false, omitiendo")
next
end
nodo_id = nodo['id']
# 3. Procesar temas del nodo
datos_nodo[:temas].each do |id_tema, datos_tema|
# Buscar tema por id_tema y bitácora
temas = @db.list_temas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100)
tema_existente = temas.find { |t| t['id_tema'] == id_tema }
if tema_existente
@logger.info("Tema #{id_tema} ya existe (ID: #{tema_existente['id']})")
tema_id = tema_existente['id']
else
tema = @db.create_tema(
id_tema: id_tema,
titulo: datos_tema[:titulo],
estado: datos_tema[:estado],
descripcion: datos_tema[:descripcion],
bitacora_id: bitacora_id,
nodo_id: nodo_id
)
tema_id = tema['id']
@estadisticas[:temas_creados] += 1
@logger.info("Tema creado: #{id_tema} (ID: #{tema_id})")
end
# 4. Procesar entradas del tema
datos_tema[:entradas].each do |entrada_md|
# Validar campos requeridos
next unless entrada_md[:inicio] && entrada_md[:descripcion]
# Intentar buscar entrada existente para evitar duplicados (Upsert)
# Buscamos por: bitacora_id, nodo_id y hora de inicio (única para un nodo en un día)
entradas_nodo = @db.list_entradas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100)
# Normalizar hora para comparación
inicio_norm = @db.send(:parse_time, entrada_md[:inicio])
existente = entradas_nodo.find { |e| e['inicio'] == inicio_norm }
begin
if existente
# Actualizar si ya existe
@db.update_entrada(existente['id'], {
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P'
})
@estadisticas[:entradas_actualizadas] ||= 0
@estadisticas[:entradas_actualizadas] += 1
else
# Crear si no existe
@db.create_entrada(
inicio: entrada_md[:inicio],
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P',
es_ia: false,
tema_id: tema_id,
bitacora_id: bitacora_id,
nodo_id: nodo_id
)
@estadisticas[:entradas_creadas] += 1
end
rescue => e
@logger.error("Error procesando entrada '#{entrada_md[:inicio]} - #{entrada_md[:descripcion]}': #{e.message}")
end
end
end
end
rescue => e
error = "Error importando estructura: #{e.message}"
@logger.error(error)
errores << error
end
{ errores: errores }
end
# Infiere tipo de nodo basado en su nombre
def inferir_tipo_nodo(nombre)
TIPOS_NODO.each do |patron, tipo|
return tipo if nombre.match?(patron)
end
'servidor' # Tipo por defecto
end
# Consolida resultados de múltiples importaciones
def consolidar_resultados(resultados)
totales = {
archivos: resultados.length,
exitosos: resultados.count { |r| r[:exitoso] == true },
omitidos: resultados.count { |r| r[:skip] == true },
errores: resultados.count { |r| r[:error] }
}
estadisticas_consolidadas = {
archivos_procesados: resultados.length,
bitacoras_creadas: resultados.sum { |r| r.dig(:estadisticas, :bitacoras_creadas) || 0 },
nodos_creados: resultados.sum { |r| r.dig(:estadisticas, :nodos_creados) || 0 },
temas_creados: resultados.sum { |r| r.dig(:estadisticas, :temas_creados) || 0 },
entradas_creadas: resultados.sum { |r| r.dig(:estadisticas, :entradas_creadas) || 0 },
errores: resultados.flat_map { |r| r.dig(:estadisticas, :errores) || [] }.compact
}
{
totales: totales,
estadisticas: estadisticas_consolidadas,
detalles: resultados
}
end
# Logger por defecto
def default_logger
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def advertencia(msg); puts "[ADVERTENCIA] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
end.new
end
end
end
end