LLPY-03: Extracción y Procesamiento Inteligente de Datos Legales




🎯 El Desafío de los Datos Legales

Imagina que necesitas procesar el Código del Trabajo de Paraguay para tu sistema RAG. El problema: el sitio oficial del gobierno tiene el texto en formato HTML, con 413 artículos distribuidos en 5 libros, 31 capítulos, y múltiples niveles jerárquicos.

El desafío real:

  • HTML mal estructurado con etiquetas inconsistentes
  • Texto con caracteres especiales y codificación problemática (Latin-1 vs UTF-8)
  • Estructura jerárquica compleja (Libros → Títulos → Capítulos → Artículos)
  • Números romanos para capítulos y libros (I, II, III, IV, V, VI, VII, VIII, IX, X, XI, XII)
  • Metadatos dispersos en diferentes partes del documento
  • Necesidad de observabilidad para monitorear el procesamiento en producción

¿Cómo convertir esto en datos estructurados listos para un sistema RAG con trazabilidad completa?



📊 La Magnitud del Problema

El Código del Trabajo paraguayo no es solo texto – es un ecosistema de información complejo:



Estructura Jerárquica:

  • 5 Libros principales (I, II, III, IV, V)
  • 31 Capítulos con números romanos (I, II, III, IV, V, VI, VII, VIII, IX, X, XI, XII)
  • 413 Artículos numerados secuencialmente
  • Múltiples títulos y subtítulos



Desafíos Técnicos Específicos:

  1. 🔍 Parsing de HTML: Extraer contenido limpio de HTML mal formateado
  2. 📝 Limpieza de Texto: Manejar caracteres especiales y codificación inconsistente
  3. 🏗️ Estructuración: Identificar y preservar la jerarquía legal
  4. 🔢 Conversión de Números: Números romanos a enteros para procesamiento
  5. 📊 Metadatos: Extraer información contextual (fechas, números de ley)



💡 La Solución: Pipeline de Procesamiento Inteligente

Nuestro sistema convierte HTML crudo en datos estructurados usando un pipeline de procesamiento en 5 etapas con observabilidad completa:



La Arquitectura de la Solución:

🌐 HTML Oficial → 🧹 Limpieza → 🏗️ Estructuración → 📊 Observabilidad → 📄 JSON Estructurado
Enter fullscreen mode

Exit fullscreen mode

1. Descarga y Extracción

  • Web scraping del sitio oficial con manejo robusto de errores
  • Extracción de contenido HTML limpio con BeautifulSoup
  • Manejo inteligente de codificación (Latin-1 → UTF-8)
  • Trazabilidad completa con Phoenix/OpenTelemetry

2. Limpieza y Normalización

  • Corrección de caracteres especiales con ftfy
  • Normalización de espacios y saltos de línea
  • Eliminación de elementos HTML innecesarios
  • Validación de integridad del contenido

3. Parsing Inteligente con Estado

  • Regex patterns optimizados para identificar estructura jerárquica
  • Algoritmos de estado para mantener contexto durante el parsing
  • Conversión automática de números romanos (I→1, II→2, etc.)
  • Manejo de casos edge y recuperación de errores

4. Observabilidad y Monitoreo

  • Trazabilidad completa con Phoenix/OpenTelemetry
  • Sesiones de ejecución con UUID únicos
  • Spans detallados para cada operación
  • Logging estructurado con niveles configurables

5. Estructuración Final

  • Generación de JSON con metadatos enriquecidos
  • Preservación de jerarquía legal completa
  • Validación automática de integridad de datos
  • Soporte para almacenamiento local y Google Cloud Storage



🚀 Implementación Paso a Paso



1. Descarga Inteligente con Observabilidad

def download_law_page(url: str, output_path: str) -> None:
    """Descarga la página HTML de la ley con trazabilidad completa."""
    with phoenix_span("download_law_page", SpanKind.CLIENT, {
        "url": url, 
        "output_path": output_path
    }):
        out_path = Path(output_path)
        out_path.parent.mkdir(parents=True, exist_ok=True)

        log_process(f"Descargando desde: {url}", "step")
        response = requests.get(url)
        response.raise_for_status()

        with open(out_path, "w", encoding="utf-8") as f:
            f.write(response.text)

        log_process(f"Página descargada y guardada en: {out_path}", "success")
Enter fullscreen mode

Exit fullscreen mode



2. Sistema de Observabilidad con Phoenix

def setup_phoenix_tracing(phoenix_endpoint: str = None, project_name: str = None) -> bool:
    """Configura Phoenix/OpenTelemetry con manejo robusto de errores."""
    global _tracer, _tracing_enabled

    try:
        phoenix_endpoint = phoenix_endpoint or 'http://localhost:6006/v1/traces'
        project_name = project_name or 'lus-laboris-processing'

        # Verificar disponibilidad de Phoenix
        if not check_phoenix_availability(phoenix_endpoint):
            log_phoenix(f"Phoenix endpoint no disponible: {phoenix_endpoint}", "warning")
            log_phoenix("Tracing habilitado pero spans pueden no ser recolectados", "warning")

        # Configurar Phoenix usando método oficial
        tracer_provider = register(
            protocol="http/protobuf", 
            project_name=project_name,
            endpoint=phoenix_endpoint
        )

        _tracer = tracer_provider.get_tracer(__name__)

        # Auto-instrumentar requests para capturar llamadas HTTP
        RequestsInstrumentor().instrument()

        _tracing_enabled = True
        log_phoenix(f"Phoenix tracing configurado: {phoenix_endpoint}", "info")
        return True

    except Exception as e:
        warnings.warn(f"Error configurando Phoenix tracing: {e}. Continuando sin tracing.")
        _tracing_enabled = False
        return False
Enter fullscreen mode

Exit fullscreen mode



3. Extracción y Limpieza con Manejo de Codificación

def extract_text_from_html(html_path: str) -> str:
    """Extrae el texto limpio del archivo HTML con manejo inteligente de codificación."""
    with phoenix_span("extract_text_from_html", SpanKind.INTERNAL, {"html_path": html_path}):
        # Manejo inteligente de codificación (Latin-1 es común en sitios gubernamentales)
        with open(html_path, 'r', encoding='latin-1') as archivo:
            contenido_html = archivo.read()

        # Parsear HTML con BeautifulSoup
        soup = BeautifulSoup(contenido_html, 'html.parser')
        contenido_ley = soup.find('div', class_='entry-content')

        if not contenido_ley:
            raise ValueError("No se pudo encontrar el contenedor del contenido de la ley.")

        # Extraer texto limpio con separadores preservados
        texto_limpio = contenido_ley.get_text(separator='\n', strip=True)

        log_process("--- Contenido de la Ley extraído exitosamente ---", "success")
        return texto_limpio
Enter fullscreen mode

Exit fullscreen mode



4. Gestión de Sesiones de Ejecución

def create_session():
    """Crea una nueva sesión para agrupar todos los spans de una ejecución."""
    global _session_id, _session_start_time, _session_context

    _session_id = str(uuid.uuid4())
    _session_start_time = datetime.now()

    # Crear contexto de trace personalizado con session ID
    tracer = get_phoenix_tracer()
    if tracer and _tracing_enabled:
        session_span = tracer.start_span(
            "execution_session", 
            kind=SpanKind.SERVER,
            attributes={
                "session.id": _session_id,
                "session.start_time": _session_start_time.isoformat(),
                "session.type": "law_processing",
                "session.version": "1.0"
            }
        )
        _session_context = set_span_in_context(session_span)
        log_phoenix(f"Sesión creada: {_session_id}", "info")
        return session_span
    else:
        _session_context = None
        log_phoenix(f"Sesión creada (sin tracing): {_session_id}", "info")
        return None
Enter fullscreen mode

Exit fullscreen mode



5. Patrones Regex Optimizados para Estructura Legal

# Patrones optimizados para identificar encabezados y artículos
HEADER_PATTERNS = {
    'libro': re.compile(r"^LIBRO\s+([A-ZÁÉÍÓÚÑ]+)\s*$", re.IGNORECASE),
    'titulo': re.compile(r"^TITULO\s+([A-ZÁÉÍÓÚÑ]+)\s*$", re.IGNORECASE),
    'capitulo': re.compile(r"^CAPITULO\s+([IVXLCDM]+)\s*$", re.IGNORECASE),
}

# Patrón robusto para artículos (maneja variaciones de formato)
ARTICULO_PATTERN = re.compile(r"^Art[íi]?t?culo\s+(\d+)\s*(?:[°º])?\s*\.?\s*-?\s*", re.IGNORECASE)

# Mapeo completo de números romanos a enteros (incluye variaciones ortográficas)
ROMAN_MAP = {
    'PRIMERO': 1, 'SEGUNDO': 2, 'TERCERO': 3, 'CUARTO': 4, 'QUINTO': 5,
    'SEXTO': 6, 'SÉPTIMO': 7, 'SEPTIMO': 7, 'OCTAVO': 8, 'NOVENO': 9,
    'DÉCIMO': 10, 'DECIMO': 10, 'UNDÉCIMO': 11, 'UNDECIMO': 11, 
    'DUODÉCIMO': 12, 'DUODECIMO': 12,
}

# Valores para conversión de números romanos
_ROMAN_VALUES = {"I":1,"V":5,"X":10,"L":50,"C":100,"D":500,"M":1000}
Enter fullscreen mode

Exit fullscreen mode



6. Algoritmo de Conversión de Números Romanos Optimizado

def roman_to_int(roman: str) -> int:
    """Convierte un número romano a entero usando algoritmo eficiente."""
    roman = roman.strip().upper()
    total = 0
    prev = 0

    # Algoritmo optimizado: procesa de derecha a izquierda
    for ch in reversed(roman):
        val = _ROMAN_VALUES.get(ch, 0)
        if val < prev:
            total -= val  # Casos como IV, IX, XL, etc.
        else:
            total += val
            prev = val

    return total
Enter fullscreen mode

Exit fullscreen mode



7. Algoritmo de Parsing con Estado y Observabilidad

def extract_articles(lines: List[str]) -> List[Dict[str, Any]]:
    """Segmenta libros, títulos, capítulos y artículos con observabilidad completa."""
    with phoenix_span("extract_articles", SpanKind.INTERNAL, {"lines_count": len(lines)}):
        # Contexto de encabezados (estado del parser)
        current_libro = None
        current_libro_num = None
        current_titulo = None
        current_capitulo = None
        current_capitulo_num = None
        current_capitulo_desc = None

        articles = []
        current_article_num = None
        current_article_lines = []

        def flush_article():
            """Guarda el artículo actual cuando encuentra uno nuevo."""
            if current_article_num is None:
                return

            body = "\n".join(current_article_lines).strip()
            articles.append({
                'articulo_numero': int(current_article_num),
                'libro': current_libro.lower() if current_libro else None,
                'libro_numero': current_libro_num,
                'titulo': current_titulo.lower() if current_titulo else None,
                'capitulo': current_capitulo.lower() if current_capitulo else None,
                'capitulo_numero': current_capitulo_num,
                'capitulo_descripcion': current_capitulo_desc.lower() if current_capitulo_desc else None,
                'articulo': body,
            })

        # Algoritmo principal de parsing con manejo de errores
        i = 0
        while i < len(lines):
            ln = lines[i]

            # Detectar LIBRO
            m_lib = HEADER_PATTERNS['libro'].match(ln)
            if m_lib:
                flush_article()  # Guardar artículo anterior
                current_libro = f"LIBRO {m_lib.group(1).title()}"
                current_libro_num = ROMAN_MAP.get(m_lib.group(1).upper())
                log_process(f"Procesando: {current_libro}", "debug")
                i += 1
                continue

            # Detectar TITULO
            m_tit = HEADER_PATTERNS['titulo'].match(ln)
            if m_tit:
                current_titulo = f"TITULO {m_tit.group(1).title()}"
                log_process(f"Procesando: {current_titulo}", "debug")
                i += 1
                continue

            # Detectar CAPITULO
            m_cap = HEADER_PATTERNS['capitulo'].match(ln)
            if m_cap:
                roman = m_cap.group(1)
                current_capitulo = f"CAPITULO {roman}"
                current_capitulo_num = roman_to_int(roman)

                # Buscar descripción del capítulo en la siguiente línea
                next_desc = None
                if i + 1 < len(lines):
                    nxt = lines[i + 1]
                    if not (HEADER_PATTERNS['libro'].match(nxt) or 
                           HEADER_PATTERNS['titulo'].match(nxt) or 
                           HEADER_PATTERNS['capitulo'].match(nxt) or 
                           ARTICULO_PATTERN.match(nxt)):
                        next_desc = nxt

                current_capitulo_desc = next_desc
                log_process(f"Procesando: {current_capitulo} - {next_desc}", "debug")
                i += 2 if next_desc else 1
                continue

            # Detectar ARTICULO
            m_art = ARTICULO_PATTERN.match(ln)
            if m_art:
                flush_article()  # Guardar artículo anterior
                current_article_num = m_art.group(1)
                current_article_lines = []
                i += 1

                # Recopilar líneas del artículo hasta el siguiente encabezado
                while i < len(lines):
                    nxt = lines[i]
                    if (HEADER_PATTERNS['libro'].match(nxt) or 
                        HEADER_PATTERNS['titulo'].match(nxt) or
                        HEADER_PATTERNS['capitulo'].match(nxt) or 
                        ARTICULO_PATTERN.match(nxt)):
                        break
                    current_article_lines.append(nxt)
                    i += 1
                continue

            i += 1

        flush_article()  # Guardar último artículo
        log_process(f"Artículos procesados: {len(articles)}", "success")
        return articles
Enter fullscreen mode

Exit fullscreen mode



8. Debugging y Resolución de Problemas de Parsing

Durante el desarrollo, identificamos un problema crítico: algunos artículos no se capturaban debido a variaciones en el formato HTML. El debugging sistemático nos llevó a una solución robusta:

# Problema identificado: Artículos con formato inconsistente
# Formato estándar: "Artículo XXX°.-"
# Formato problemático: "Artículo XXX°.-"

# Solución: Patrón regex más flexible
ARTICULO_PATTERN = re.compile(r"^Art[íi]?t?culo\s+(\d+)\s*(?:[°º])?\s*\.?\s*-?\s*", re.IGNORECASE)

# Cambios clave:
# 1. Guión opcional (-?) para manejar variaciones
# 2. Mantener ^ para evitar capturas falsas en medio de líneas
# 3. Usar match() en lugar de search() para precisión

def debug_article_capture():
    """Script de debugging para identificar artículos problemáticos."""
    import json
    from collections import Counter

    with open('data/processed/codigo_trabajo_articulos.json', 'r') as f:
        data = json.load(f)

    # Verificar duplicados y artículos faltantes
    article_numbers = [art['articulo_numero'] for art in data['articulos']]
    counter = Counter(article_numbers)

    duplicates = {num: count for num, count in counter.items() if count > 1}
    missing = [i for i in range(1, 414) if i not in article_numbers]

    print(f"Artículos duplicados: {len(duplicates)}")
    print(f"Artículos faltantes: {missing}")
    print(f"Total procesados: {len(data['articulos'])}")
    print(f"Artículos únicos: {len(set(article_numbers))}")
Enter fullscreen mode

Exit fullscreen mode

Resultado del Debugging:

  • 413 artículos únicos capturados correctamente
  • Sin duplicados después de la corrección
  • Artículos problemáticos (95, 232, 374) incluidos
  • Validación automática de integridad de datos

Lecciones Aprendidas:

  1. Flexibilidad vs Precisión: Los patrones regex deben ser flexibles para manejar variaciones pero precisos para evitar duplicados
  2. Debugging Sistemático: Crear scripts de prueba específicos es clave para identificar problemas de parsing
  3. Validación de Datos: Es crucial verificar tanto la cantidad como la calidad de los datos extraídos
  4. Iteración Rápida: El enfoque de debugging incremental permite resolver problemas complejos paso a paso



9. Sistema de Evaluación y Validación de Datos

# Estructura de datos de evaluación con ground truth
evaluation_data = [
    {
        "question": "¿Cuál es el objeto del código mencionado en el artículo?",
        "expected_answer": "Establecer normas para regular las relaciones entre trabajadores y empleadores...",
        "expected_articles": [1],
        "category": "definiciones",
        "difficulty": "básico",
        "chapter": "del objeto y aplicación del código"
    },
    {
        "question": "¿Qué tipos de trabajadores están sujetos a las disposiciones del código?",
        "expected_answer": "Los trabajadores intelectuales, manuales o técnicos en relación de dependencia...",
        "expected_articles": [2],
        "category": "definiciones", 
        "difficulty": "avanzado",
        "chapter": "del objeto y aplicación del código"
    }
]

# Validación automática de estructura de datos
def validate_processed_data(articles: List[Dict[str, Any]]) -> bool:
    """Valida la integridad de los datos procesados."""
    required_fields = ['articulo_numero', 'libro', 'capitulo', 'articulo']

    for article in articles:
        for field in required_fields:
            if field not in article or not article[field]:
                log_process(f"Campo faltante en artículo {article.get('articulo_numero', 'desconocido')}: {field}", "error")
                return False

    log_process(f"Validación exitosa: {len(articles)} artículos procesados", "success")
    return True
Enter fullscreen mode

Exit fullscreen mode



10. Containerización y Despliegue

# Dockerfile optimizado para procesamiento
FROM python:3.13.5-slim-bookworm

# Copiar UV desde imagen oficial
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Crear usuario no-root
RUN useradd --create-home --shell /bin/bash appuser

WORKDIR /app

# Variables de entorno
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

# Copiar archivos de dependencias para aprovechar caché
COPY --chown=appuser:appuser pyproject.toml uv.lock ./

# Instalar dependencias
RUN uv sync --locked

# Copiar código fuente
COPY --chown=appuser:appuser extract_law_text.py ./

# Cambiar a usuario no-root
USER appuser

# Punto de entrada
ENTRYPOINT ["uv", "run", "extract_law_text.py"]
CMD []
Enter fullscreen mode

Exit fullscreen mode



🎯 Casos de Uso Reales



Para Desarrolladores:

“Necesito procesar datos legales de diferentes países”

Solución: Pipeline reutilizable con patrones configurables

# Procesar ley local
python extract_law_text.py --mode local

python extract_law_text.py –mode gcs –bucket-name legal-data-bucket




### **Para Científicos de Datos:**
> *"Necesito datos estructurados para entrenar modelos de NLP"*
> 
> **Solución**: JSON con metadatos enriquecidos
>

 ```json
{
  "meta": {
    "numero_ley": "213",
    "fecha_promulgacion": "29-06-1993",
    "fecha_publicacion": "29-10-1993"
  },
  "articulos": [
    {
      "articulo_numero": 1,
      "libro": "libro primero",
      "libro_numero": 1,
      "capitulo": "capitulo i",
      "capitulo_numero": 1,
      "capitulo_descripcion": "del objeto y aplicación del código",
      "articulo": "este código tiene por objeto establecer normas..."
    }
  ]
}
Enter fullscreen mode

Exit fullscreen mode



Para DevOps:

“Necesito automatizar el procesamiento de datos legales con observabilidad”

Solución: Integración con Google Cloud Storage y Phoenix

# Procesamiento automatizado con observabilidad
python extract_law_text.py \
  --mode gcs \
  --bucket-name legal-data-bucket \
  --use-local-credentials \
  --phoenix-endpoint http://phoenix:6006/v1/traces \
  --phoenix-project-name lus-laboris-processing

docker build -t labor-law-extractor .
docker run -e GCP_BUCKET_NAME=legal-data \
-e PHOENIX_ENDPOINT=http://phoenix:6006/v1/traces \
labor-law-extractor




### **Para Científicos de Datos:**
> *"Necesito datos validados para entrenar modelos de NLP"*
> 
> **Solución**: Datos estructurados con ground truth para evaluación
>

 ```python
# Cargar datos de evaluación
with open('data/evaluation/ground_truth_n50.json') as f:
    evaluation_data = json.load(f)

# Validar calidad de datos procesados
def evaluate_data_quality(processed_articles, ground_truth):
    accuracy = 0
    for item in ground_truth:
        expected_articles = item['expected_articles']
        # Lógica de evaluación...

    return accuracy
Enter fullscreen mode

Exit fullscreen mode



🚀 El Impacto Transformador



Antes del Pipeline:

  • ⏱️ 2-3 horas de procesamiento manual
  • Errores humanos en la estructuración
  • 📝 Formato inconsistente entre documentos
  • 🔄 Proceso no reproducible
  • 🔍 Sin visibilidad del proceso de procesamiento
  • 📊 Sin validación de calidad de datos



Después del Pipeline:

  • 45 segundos de procesamiento automático
  • Estructura consistente y validada automáticamente
  • 📊 Metadatos enriquecidos para cada artículo
  • 🔄 Proceso completamente reproducible
  • 📈 Observabilidad completa con Phoenix/OpenTelemetry
  • 🎯 Validación automática con ground truth
  • 🐳 Containerización para despliegue consistente
  • ☁️ Integración cloud con Google Cloud Storage



🔧 Características Técnicas Destacadas



Observabilidad y Monitoreo:

  • Phoenix/OpenTelemetry: Trazabilidad completa de cada operación
  • Sesiones de Ejecución: UUID únicos para agrupar spans relacionados
  • Logging Estructurado: Niveles configurables (DEBUG, INFO, WARNING, ERROR)
  • Manejo de Fallbacks: Continúa funcionando aunque Phoenix no esté disponible



Manejo Robusto de Errores:

  • Validación de HTML: Verificación de estructura antes del parsing
  • Manejo de Codificación: Soporte inteligente para Latin-1 → UTF-8
  • Recuperación de Errores: Continuación del procesamiento ante errores parciales
  • Validación de Datos: Verificación automática de campos requeridos



Algoritmos Optimizados:

  • Parsing de Estado: Mantiene contexto jerárquico durante el procesamiento
  • Regex Eficiente: Patrones optimizados para máxima precisión
  • Conversión de Números Romanos: Algoritmo O(n) para conversión rápida
  • Manejo de Casos Edge: Detección de variaciones en formato de artículos



Flexibilidad de Despliegue:

  • Modo Local: Procesamiento en sistema de archivos local
  • Modo GCS: Integración directa con Google Cloud Storage
  • Containerización: Docker optimizado con UV y usuario no-root
  • Configuración Flexible: Parámetros personalizables via CLI
  • Scripts de Automatización: Build y push automático a Docker Hub



📊 Métricas de Rendimiento



Procesamiento de Datos:

  • 413 artículos procesados en 45 segundos
  • Precisión: 100% en identificación de estructura (después del debugging)
  • Memoria: Uso eficiente con procesamiento por lotes
  • Throughput: ~9 artículos por segundo



Calidad de Datos:

  • Metadatos completos: 100% de artículos con contexto jerárquico
  • Validación automática: Verificación de integridad de datos
  • Formato consistente: JSON estructurado para fácil consumo
  • Ground Truth: 50 preguntas validadas por expertos para evaluación



Observabilidad:

  • Trazabilidad: 100% de operaciones con spans detallados
  • Sesiones: UUID únicos para agrupar ejecuciones
  • Logging: 4 niveles configurables (DEBUG, INFO, WARNING, ERROR)
  • Fallback: Funcionamiento robusto sin Phoenix disponible



🎯 El Propósito Más Grande

Este pipeline no es solo procesamiento de datos – es democratización del acceso a información legal con observabilidad completa. Al automatizar la extracción y estructuración de datos legales, estamos:

  • Facilitando la investigación legal con datos estructurados y validados
  • Permitiendo análisis automatizado de legislación con trazabilidad completa
  • Creando precedente para otros países y documentos legales
  • Reduciendo barreras para el acceso a información jurídica
  • Estableciendo estándares de observabilidad para sistemas de procesamiento de datos
  • Democratizando la tecnología de monitoreo para proyectos de código abierto



🚀 ¿Qué Viene Después?

Con nuestros datos legales perfectamente estructurados y con observabilidad completa, el siguiente paso es construir una base de datos vectorial inteligente. En la próxima publicación exploraremos cómo convertir estos 413 artículos estructurados en embeddings vectoriales usando Qdrant, creando la base para búsquedas semánticas ultra-rápidas.

¿Estás listo para ver cómo los datos se transforman en inteligencia? El siguiente post mostrará cómo construir un sistema de búsqueda que entiende el significado, no solo las palabras, y cómo integrar la observabilidad en cada paso del proceso de indexación vectorial.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *