Mostrando entradas con la etiqueta Python. Mostrar todas las entradas
Mostrando entradas con la etiqueta Python. Mostrar todas las entradas

lunes, 15 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (VIII)

Reconocimiento de entidades (NER) (VII)

Continuamos ahora la entrada anterior. En ella planteamos que los SLM son adecuados para extraer las NER en local, permitiendo que el tratamiento dependa exclusivamente del blindaje de nuestro propio equipo y no de servicios en la nube. No obstante observamos que esos modelos pequeños de lenguaje presentan determinadas limitaciones en la gestión de tareas de cierta complejidad. En esta entrada abordaremos cómo superar esos techos mediante la segmentación de procesos y el desarrollo de un modelo mixto.

Los resultados del empleo directo del SLM demostraron que las limitaciones de los obtenidos mediante el script no se debían a una incapacidad para extraer datos (capacidad del modelo para leer el texto), sino a la falta de recursos para abordar simultáneamente dos tareas: esa lectura y la estructuración del formato JSON.

Es cierto que existe un margen de mejora mediante el ajuste del prompt haciendo uso de estrategias de prompt engineering, y es conveniente investigar este enfoque; pero cuando la causa radica en las restricciones estructurales, la mejora del prompt no es la solución: los datos de uso indican claramente que el cuello de botella no está en la claridad de las instrucciones, sino en las limitaciones de la memoria de trabajo del modelo.

Además pudimos comprobar en la práctica que modificaciones de la redacción de prompt, lejos de mejorar los resultados, solo trasladaban la causa del fallo de un punto a otro sin resolver el problema de fondo. Esto provocaba un comportamiento pendular de ineficiencia del que era imposible salir. Sólo el cambio de perspectiva consiguió una mejora significativa: la segmentación de procesos.

Este es el script unificado de ambas fases, resultante de la fusión en uno de lo que originalmente fueron dos script diferenciados aunque relacionados.



import json
import re
from typing import Literal
from ollama import chat
from pydantic import BaseModel, create_model

# ==========================================
# 0. CONFIGURACIÓN Y DATOS DE ENTRADA
# ==========================================

MODELO = 'llama3.2:3b'

texto_analizar = (
    "El alumno Alejandro Martínez Soler, escolarizado en 4º de Primaria del CEIP San Juan Bautista "
    "de Oviedo, presenta dificultades de adaptación significativas. La tutora, Doña Carmen Villalobos, "
    "informa de que tras la reunión mantenida el pasado martes 12 de mayo con la madre del menor, "
    "la Sra. Elena Soler, no se ha observado mejoría en el aula. El Equipo de Orientación Educativa "
    "(EOEP) ha sido notificado por el Director del centro, Don Roberto Pando, clasificando el expediente "
    "como intervención prioritaria. Se ha solicitado formalmente la colaboración del Servicio de Salud "
    "del Principado de Asturias (SESPA) para coordinar la valoración psiquiátrica del menor antes "
    "del viernes 19 de junio. Nota: Toda la documentación clínica de Alejandro ha sido archivada en "
    "el aplicativo institucional SAECE según el protocolo de la Consejería de Educación."
)

# ==========================================
# 1. FASE 1: EXTRACCIÓN DE BLOQUES SEMÁNTICOS
# ==========================================

PROMPT_SISTEMA_FASE1 = """
[OBJETIVO]
Localiza y extrae las frases, nombres, fechas e instituciones que representan entidades clave en el texto.
Devuelve el resultado como una lista de texto plano, un elemento por línea, precedido por '- '.

[REGLAS]
1. Extrae los bloques semánticos completos (ej: si hay un nombre con tratamiento de cortesía o cargo, extráelo completo).
2. NO incluyas introducciones, notas ni explicaciones.
3. Conserva las mayúsculas y siglas idénticas al original.

[EJEMPLO]
INPUT: El orientador del CEIP Marcelo Gago de Avilés remitió el informe técnico a la Consejería el pasado lunes 5 de octubre.
OUTPUT:
- El orientador
- CEIP Marcelo Gago
- Avilés
- la Consejería
- lunes 5 de octubre
"""

print("Ejecutando Fase 1 (Extracción de Bloques Semánticos)...")

response_fase1 = chat(
    model=MODELO,
    messages=[
        {'role': 'system', 'content': PROMPT_SISTEMA_FASE1},
        {'role': 'user', 'content': f"INPUT: {texto_analizar}"}
    ],
    options={'temperature': 0.0, 'top_p': 0.1}
)

salida_bruta = response_fase1.message.content
lines = [line.strip().lstrip('- ').strip() for line in salida_bruta.strip().split('\n') if line]

# --- CAPA DE LIMPIEZA DETERMINISTA (PYTHON) ---
STOP_WORDS_BLOQUES = {
    'alumno', 'menor', 'tutora', 'reunión', 'madre', 'director', 
    'aplicativo institucional', 'intervención prioritaria', 'informe'
}

lista_entidades_fase1 = []
for cadena in lines:
    # 1. Eliminar artículos y fórmulas de cortesía al inicio mediante regex
    procesada = re.sub(r'^(el|la|los|las|del|de|don|doña|sr|sra|sra\.)\s+', '', cadena, flags=re.IGNORECASE)
    
    # 2. Filtrar si coincide con una stop word o si quedó vacía
    if procesada.lower() not in STOP_WORDS_BLOQUES and len(procesada) > 2:
        lista_entidades_fase1.append(procesada)

print(f"-> Fase 1 Finalizada. Se detectaron {len(lista_entidades_fase1)} entidades potenciales.")


# ==========================================
# 2. FASE 2: CLASIFICACIÓN SEMÁNTICA (NER)
# ==========================================

# Definimos la taxonomía estricta mediante Literal
CategoriasNER = Literal[
    'ALUMNO', 'ROL_PROFESIONAL', 'CENTRO_EDUCATIVO', 
    'UBICACION', 'FAMILIAR', 'FECHA', 'ORGANISMO', 'APLICATIVO'
]

# Creamos el esquema Pydantic dinámico usando la salida real de la Fase 1
EsquemaClasificacion = create_model(
    'EsquemaClasificacion',
    __base__=BaseModel,
    **{entidad: (CategoriasNER, ...) for entidad in lista_entidades_fase1}
)

PROMPT_SISTEMA_FASE2 = """
[TASK]
Clasifica cada uno de los términos de la lista proporcionada asignándole exclusivamente una de las categorías permitidas.

[CATEGORÍAS PERMITIDAS]
- ALUMNO: Nombres de estudiantes.
- ROL_PROFESIONAL: Cargos, puestos, figuras docentes o equipos de orientación (individuales o colectivos).
- CENTRO_EDUCATIVO: Nombres de escuelas o colegios.
- UBICACION: Localidades, ciudades o provincias.
- FAMILIAR: Relaciones familiares o nombres de familiares del alumno.
- FECHA: Expresiones temporales.
- ORGANISMO: Servicios públicos, organismos de salud o consejerías.
- APLICATIVO: Software o plataformas informáticas.

[CRITICAL RULES]
1. Responde única y exclusivamente con el JSON estructurado según el esquema.
2. No agregues introducciones, notas ni texto fuera del JSON.

[EJEMPLO DE EJECUCIÓN]
INPUT LIST:
- CEIP Marcelo Gago
- Avilés
- lunes 5 de octubre
OUTPUT JSON:
{
    "CEIP Marcelo Gago": "CENTRO_EDUCATIVO",
    "Avilés": "UBICACION",
    "lunes 5 de octubre": "FECHA"
}

[FIN DEL EJEMPLO]
"""

# Convertimos la lista depurada en texto plano para el prompt
input_model_fase2 = "\n".join([f"- {item}" for item in lista_entidades_fase1])

print("\nEjecutando Fase 2 (Clasificación Semántica Estructurada)...")

response_fase2 = chat(
    model=MODELO,
    messages=[
        {'role': 'system', 'content': PROMPT_SISTEMA_FASE2},
        {'role': 'user', 'content': f"INPUT LIST:\n{input_model_fase2}"}
    ],
    format=EsquemaClasificacion.model_json_schema(),
    options={'temperature': 0.0}
)

# ==========================================
# 3. SALIDA FINAL CONSOLIDADA
# ==========================================

try:
    clasificacion_final = json.loads(response_fase2.message.content)
    print("\n--- Resultado Final Consolidado (NER Estructurado) ---")
    print(json.dumps(clasificacion_final, indent=4, ensure_ascii=False))
except Exception as e:
    print(f"\nError al estructurar el JSON final: {e}")
    print("Respuesta cruda del modelo:", response_fase2.message.content)


Este script presenta una arquitectura ajustada a los modelos de lenguaje pequeños y a sus limitaciones. En lugar de pedirle al modelo que extraiga y clasifique todo en un solo paso, el script implementa un patrón de diseño conocido como pipeline de dos fases con validación estricta y que obedece al siguiente esquema:

En la fase 1 se procede a la extracción de los datos mediante SLM y un prompt localizador de texto" que exige al modelo que extraiga bloques semánticos completos. Se definen hiperparámetros (temperature: 0.0 y top_p: 0.1) que fuerza al modelo a un comportamiento determinista y a ceñirse estrictamente al texto.

Además de la extracción de texto, en la fase 1 implementamos una capa híbrida de limpieza determinista mediante Python puro para que asuma funciones de limpieza de ruido en las que este modelo es mucho más eficiente que un modelo de lenguaje. Para esa limpieza empleamos estructuras RegEx (re.sub) y Stop Words (STOP_WORDS_BLOQUES

La fase 2 se inicia con la creación de un modelo dinámico basado en Pydantic. En este punto, el script genera en tiempo de ejecución una estructura de datos adaptada a las entidades encontradas en la fase anterior, definiendo un conjunto estricto de categorías permitidas.

Tras este proceso, de nuevo interviene el SLM para ejecutar el prompt de clasificación. Gracias a que el esquema de Pydantic se inyecta directamente en el motor de inferencia, el modelo queda restringido a devolver, única y exclusivamente, el JSON estructurado con el NER resultante, que es el siguiente:

{
"Alejandro Martínez Soler": "ALUMNO",
"CEIP San Juan Bautista": "CENTRO_EDUCATIVO",
"Oviedo": "UBICACION",
"Carmen Villalobos": "FAMILIAR",
"madre del menor": "FAMILIAR",
"Sra. Elena Soler": "FAMILIAR",
"pasado martes 12 de mayo": "FECHA",
"Roberto Pando": "ALUMNO",
"Equipo de Orientación Educativa (EOEP)": "ROL_PROFESIONAL",
"Director del centro": "ROL_PROFESIONAL",
"Servicio de Salud del Principado de Asturias (SESPA)": "ORGANISMO",
"viernes 19 de junio": "FECHA"
}

Como podemos observar en la salida del script, el formateo es impecable gracias al blindaje de Pydantic. Sin embargo, un modelo pequeño de lenguaje puede perder la relación de contexto y cometer errores importantes. Con todo, la mejora de resultados respecto al enfoque anterior es evidente, lo que demuestra que la segmentación del proceso y la combinación de Python + SLM presenta ventajas respecto a un modelo IA puro, ahorrando un 90% del trabajo de extracción.

Además, aun caben posibilidades de mejora de esta arquitectura modular... pero también plantear una tercera vía —la cual ya hemos esbozado en nuestro último script—: dividir el proceso de extracción de entidades combinando un modelo heurístico basado en SpaCy con otro basado en IA local, para que cada tecnología asuma las tareas en las que presenta mejor rendimiento.

Este será el comentido de la próxima entrada.

domingo, 14 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (VII)

Reconocimiento de entidades (NER) (VI)

Finalizaba la entrada anterior planteando la existencia de alternativas al modelo heurístico por pipeline híbrido, las cuales se concretan en varias opciones teóricas según vimos anteriormente). En realidad, esas opciones son eso, más teóricas que reales, ya que ni la basada en ML clásico ni la basada en DL discriminativo son realmente viables en función de las restricción reales de datos (confidencialidad, dificultad para conseguir los datos suficientes y coste del etiquetado) ni de hardware (limitaciones de procesamiento y de memoria) que puede afrontar un organismo con escasos recursos como es un SEO. La consecuencia es que sólo es viable la opción basada en IA generativa, pero únicamente la que se puede correr en local, lo que conlleva, además, una limitación en cuanto a los modelos de lenguaje a emplear, que no son otros que los modelos pequeños o SLM. Pero lo que esto no garantizada a priori es la viabilidad real y funcional del modelo IA local.

En consecuencia, la única alternativa viable es la IA generativa ejecutada en local. Esto conlleva, además, una restricción en los modelos de lenguaje a emplear, reduciéndolos a modelos pequeños o SLM. No obstante, esta viabilidad técnica no garantiza, a priori, la eficacia funcional del modelo de IA local para resolver un determinado problema. En el contexto de esta entrada y de las que la preceden, el problema a resolver es (sigue siendo) la extracción de las NER de un texto sintético que ya nos es conocido.

Como tendremos ocasión de comprobar en esta entrada, en la práctica pretender replicar la competencia de un LLM comercial online mediante el simple prompting de un SLM local es una vía infructuosa; las limitaciones en la capacidad de atención y la baja densidad de parámetros de estos modelos pequeños afectan radicalmente a su precisión en el desempeño de tareas como la extracción de datos a partir de un texto no estructurado. Por tanto, alcanzar una equivalencia funcional exige un cambio estructural en el procedimiento. Dado que el modelo en la nube queda descartado por motivos estrictos de confidencialidad, la solución real no radica en el aislamiento del modelo de lenguaje, sino en el diseño de una arquitectura híbrida: un pipeline robusto donde un modelo heurístico asuma la segmentación previa y la estructuración del texto, delegando en el SLM únicamente la extracción fina allí donde las reglas rígidas no alcanzan. El éxito, en consecuencia, pasa de depender de la potencia del modelo a depender de la inteligencia del proceso.

Dado que el tema requiere mucho más espacio del que podemos darle en esta entrada atendiendo a su propia finalidad, no me voy a detener en explicar qué es Ollama y cómo hemos llegado hasta el punto en que somos capaces de utilizar un modelo pequeño de lenguaje SLM como herramienta de trabajo; simplemente lo damos por ahora como un hecho , aunque nos tengamos que detener en algún detalle a la hora de explicar el funcionamiento del script.



# 0. Implementamos las bibliotecas necesarias: json, ollama, pydantic y typing

import json
from ollama import chat
from pydantic import BaseModel, Field
from typing import List

# 1. Definimos el esquema de salida esperado usando pydantic

class EntidadExtraida(BaseModel):
    texto: str = Field(description="El fragmento de texto exacto tal como aparece en el documento original.")
    etiqueta: str = Field(
        description=(
            "La categoría de la entidad. Debe ser una de las siguientes: "
            "'ALUMNO', 'ROL_PROFESIONAL', 'CENTRO_EDUCATIVO', 'UBICACION', "
            "'FAMILIAR', 'FECHA', 'ORGANISMO', 'APLICATIVO'."
        )
    )

class ResultadoNER(BaseModel):
    entidades: List[EntidadExtraida]

# 2. Entregamos el documento a procesar (obtener NER)

texto_analizar = (
    "El alumno Alejandro Martínez Soler, escolarizado en 4º de Primaria del CEIP San Juan Bautista "
    "de Oviedo, presenta dificultades de adaptación significativas. La tutora, Doña Carmen Villalobos, "
    "informa de que tras la reunión mantenida el pasado martes 12 de mayo con la madre del menor, "
    "La Sra. Elena Soler, no se ha observado mejoría en el aula. El Equipo de Orientación Educativa "
    "(EOEP) ha sido notificado por el Director del centro, Don Roberto Pando, clasificando el expediente "
    "como intervención prioritaria. Se ha solicitado formalmente la colaboración del Servicio de Salud "
    "del Principado de Asturias (SESPA) para coordinar la valoración psiquiátrica del menor antes "
    "del viernes 19 de junio. Nota: Toda la documentación clínica de Alejandro ha sido archivada en "
    "el aplicativo institucional SAECE según el protocolo de la Consejería de Educación."
)

print("Enviando petición a llama3.2:3b...")

# 3. Llamada a Ollama utilizando salida estructurada (en el paso 1). Incluye la llamada al SLM y el prompt

response = chat(
    model='llama3.2:3b',
    messages=[
        {
            'role': 'system',
            'content': (
                "Eres un asistente experto en procesamiento del lenguaje natural especializado en el ámbito educativo. "
                "Tu tarea es realizar Reconocimiento de Entidades Nombradas (NER) sobre el texto proporcionado. "
                "Extrae únicamente las entidades literales que correspondan a estas categorías estrictas:\n"
                "- ALUMNO: Nombre del estudiante.\n"
                "- ROL_PROFESIONAL: Cargos, puestos o figuras docentes/técnicas (ej. tutora, Director, Equipo de Orientación Educativa, EOEP).\n"
                "- CENTRO_EDUCATIVO: Nombres de colegios o institutos.\n"
                "- UBICACION: Ciudades, municipios o provincias.\n"
                "- FAMILIAR: Nombres de padres, madres o tutores legales.\n"
                "- FECHA: Referencias temporales explícitas.\n"
                "- ORGANISMO: Entidades públicas, servicios de salud o consejerías.\n"
                "- APLICATIVO: Software, plataformas web o bases de datos institucionales.\n"
                "Sé riguroso y extrae solo texto literal, sin omitir ni inventar nada."
            )
        },
        {
            'role': 'user',
            'content': f"Analiza el siguiente texto:\n\n{texto_analizar}"
        }
    ],
    format=ResultadoNER.model_json_schema(), # Forzamos la estructura JSON mediante el esquema de Pydantic
    options={'temperature': 0.0}  			 # Forzamos a literalidad tratsando de evitar alucinaciones
)

# 4. Procesar y mostrar el resultado

try:
    datos_extraidos = json.loads(response.message.content)
    print("\n--- Entidades Detectadas por llama3.2:3b ---")
    print(json.dumps(datos_extraidos, indent=4, ensure_ascii=False))
except Exception as e:
    print(f"Error al parsear la respuesta: {e}")
    print("Respuesta cruda del modelo:", response.message.content)


Dentro de este script podemos identificar varias partes, empezando por las bibliotecas necesarias para su funcionamiento: json, ollama, pydantic y typing. Cada una de ellas aporta componentes fundamentales para el logro del objetivo propuesto: ollama proporciona el canal de comunicación con el modelo SLM, que es el encargado de leer y "comprender" el documento, pero las demás contribuyen determinando el correcto funcionamiento de éste para que el análisis y la salidad se ajuste estrictamente al objetivo.

El esquema de funcionamiento del script queda descrito por el orden de acciones que se recogen en los comentarios:

  • Primero definimos qué queremos obtener y cómo mediante pydantic y typing
  • Despues porporcionamos el material (texto o conjunto de datos no estructurados) sobre el que trabajar
  • En tercer lugar implementamos el SLM y el instrumento que permite la comunicación entre con él (Ollama), precisamente en orden inverso. En este punto se acoplan las directrices de rol del system prompt con el esquema JSON derivado de Pydantic, fijando la temperatura en 0.0 para garantizar el determinismo absoluto de la prueba.
  • finalmente procesamos los resultados y los mostramos en la consola, controlando cualquier posible error mediante una estructura try...except.

Una breve nota aclaratoria sobre el uso de una herramienta de IA como es Ollama dentro de este script, sin más pretensiones. El uso de un servicio Chat de IA generativa, como Gemini o Chat-GPT, puede dar a entender que esa es, si no la única, sí la principal forma de utilizar la IA, pero lo cierto es que existen otras formas que pasan por integrarla dentro de un script de Python (por ejemplo). Y esto es precisamente lo que hemos hecho en el script anterior; algo que, como podemos observar, resulta muy fácil de realizar en Python al contar con bibliotecas específicas, en nuestro caso from ollama import chat .

En sentido estricto y sumamente restringido, import ollama y...



response = chat(
    model='llama3.2:3b'
    

... son las instrucciones que hacen posible que usemos Ollama response = chat( y el SLM seleccionado model='llama3.2:3b', al que sigue el prompt, que es fundamental para su implementación. Este prompt debe ajustarse a una redacción concreta, ya que de ella depende el funcionamiento efectivo del SLM. Esto es válido también para los LLM, pero aun más para un SLM, dadas sus características y limitaciones respecto a sus hermanos mayores. El prompt que implementamos en este script se puede considerar ajustado a esos condicionantes.



response = chat(
    model='llama3.2:3b',
    messages=[
        {
            'role': 'system',
            'content': (
                "Eres un asistente experto en procesamiento del lenguaje natural especializado en el ámbito educativo. "
                "Tu tarea es realizar Reconocimiento de Entidades Nombradas (NER) sobre el texto proporcionado. "
                "Extrae únicamente las entidades literales que correspondan a estas categorías estrictas:\n"
                "- ALUMNO: Nombre del estudiante.\n"
                "- ROL_PROFESIONAL: Cargos, puestos o figuras docentes/técnicas (ej. tutora, Director, Equipo de Orientación Educativa, EOEP).\n"
                "- CENTRO_EDUCATIVO: Nombres de colegios o institutos.\n"
                "- UBICACION: Ciudades, municipios o provincias.\n"
                "- FAMILIAR: Nombres de padres, madres o tutores legales.\n"
                "- FECHA: Referencias temporales explícitas.\n"
                "- ORGANISMO: Entidades públicas, servicios de salud o consejerías.\n"
                "- APLICATIVO: Software, plataformas web o bases de datos institucionales.\n"
                "Sé riguroso y extrae solo texto literal, sin omitir ni inventar nada."
            )
        },
        {
            'role': 'user',
            'content': f"Analiza el siguiente texto:\n\n{texto_analizar}"
        }
    ],
    

A priori, este diseño satisface los niveles de exigencia del prompting para trabajar con modelos de lenguaje:

  • Asignación de rol que ayuda al modelo a, probabilísticamente, "acotar" el vocabulario y entender el contexto institucional.
  • Instrucciones de restricción rigurosas mediante el uso de palabras ("únicamente", "categorías estrictas" y "solo texto literal, sin omitir ni inventar nada") que actuan como barreras efectivas contra las alucinaciones.
  • Taxonomía con ejemplos In-line, como en ROL_PROFESIONAL, donde se incluyen ejemplos del dominio (tutora, Director, EOEP), lo que permite acotar entidades (v.g. identificar si "Equipo de Orientación" es un rol o un organismo).

Hagamos ahora un sencillo ejercicio de comparación entre lo que nos revuelve el uso de este prompt con Gemini y su LLM...

ALUMNO: Alejandro Martínez Soler
ROL_PROFESIONAL: tutora, Carmen Villalobos, Equipo de Orientación Educativa, EOEP, Director, Roberto Pando
CENTRO_EDUCATIVO: CEIP San Juan Bautista
UBICACION: Oviedo
FAMILIAR: Elena Soler
FECHA: martes 12 de mayo, viernes 19 de junio
ORGANISMO: Servicio de Salud del Principado de Asturias, SESPA, Consejería de Educación
APLICATIVO: SAECE

... y el resultado de su empleo con Ollama + llama3.2:3b

- ALUMNO: Alejandro Martínez Soler
- ROL_PROFESIONAL:
* Tutora: Doña Carmen Villalobos
* Director: Don Roberto Pando
* Equipo de Orientación Educativa (EOEP)
- CENTRO_EDUCATIVO: CEIP San Juan Bautista de Oviedo
- UBICACION: Oviedo
- FAMILIAR:
* Elena Soler (madre del alumno)
* FECHA:
* pasado martes 12 de mayo
* viernes 19 de junio
- ORGANISMO: Servicio de Salud del Principado de Asturias (SESPA)
- APLICATIVO: SAECE

Resulta muy interesante comprobar que el modelo LLM no consigue mejores resultados que el SLM en la tarea propuesta (NER) y sobre un texto plano; esto refuerza la idea de que hoy en día es posible obtener buenos resultados en según que tareas trabajando con modelos pequeños en local, lo que hace posible desarrollar proyectos con estas tecnologías con confianza en los resultados que podemos obtener, facilitando así dar respuesta a condicionantes como son los derivados de la privacidad. no obstante estos modelos no son superiores, ni siquiera iguales, a los LLM en cuanto la tarea se complica o entramos en contextos más extensos o interconectados. Pero cada cosa a su tiempo. De momento nos podemos quedar con lo que hasta ahora nos resulta útil: para un NER, un SLM corriendo en local puede que no tenga mucho que envidiar a un LLM... incluso es posible que se inviertan los papeles...

Pero veamos qué obtenemos al aplicar nuestro script:

{
"entidades": [
{
"texto": "Alejandro Martínez Soler",
"etiqueta": "ALUMNO"
},
{
"texto": "Doña Carmen Villalobos",
"etiqueta": "ROL_PROFESIONAL"
},
{
"texto": "La Sra. Elena Soler",
"etiqueta": "FAMILIAR"
},
{
"texto": "Don Roberto Pando",
"etiqueta": "ROL_PROFESIONAL"
},
{
"texto": "CEIP San Juan Bautista de Oviedo",
"etiqueta": "CENTRO_EDUCATIVO"
},
{
"texto": "Oviedo",
"etiqueta": "UBICACION"
},
{
"texto": "SEPA",
"etiqueta": "ORGANISMO"
},
{
"texto": "SAECE",
"etiqueta": "APLICATIVO"
}
]
}

Es de destacar el cambio formal que se ha producido, ya que pasamos de un listado estructurado de elementos a la expresión formal de una estructura JSON. Pero además también podemos comprobar cómo el mismo SLM que antes extraía los datos solicitados, ahora comete muchos más errores y omisiones. La explicación hay que buscarla en la naturaleza del modelo (y sus limitaciones) enfrentada a las exigencias de la tarea: al ser texto y prompt los mismos, la diferencia estriba en la exigencia de devolver una estructura que obliga a prestar atención a muchos detalles de forma como es un JSON. Ante todo ello, un modelo pequeño de lenguaje es incapaz de ofrecer una respuesta de calidad, la misma que sí puede ofrecer si no se le exige más allá de sus posibilidades. Esto abre una segunda vía de trabajo o, si se prefiere, un diseño en el que el código Python se complemente eficientemente con la IA.

Hasta aquí esta entrada, que evidentemente queda inconclusa. Lo que resta, que no es poco, para la próxima.

NOTAS
1 Puede que sea necesario dedicar una entrada al tema, pero diré que, más por cacharrear que por otra cosa, lo cierto es que tengo instalado Ollama en mi ordenador y con este servicio una serie de modelos. La instalación de Ollama no supone ninguna dificultad y la instalación de los modelos tampoco, así que me voy a ahorrar en estos momentos.
2 Según Gemini, en sentido estricto, Ollama es un entorno de ejecución (runtime) y un gestor de modelos ligero y de código abierto, diseñado para empaquetar, desplegar y ejecutar Modelos de Lenguaje (LLMs y SLMs) de forma local. No es un modelo de inteligencia artificial en sí mismo, sino la infraestructura que permite interactuar con ellos sin dependencia de servicios en la nube.

viernes, 15 de mayo de 2026

DATOS. Tratamiento de datos

Limpieza de datos (II)

Normalización de datos

Cuando en la tabla-formulario de un documento de texto cabe la posibilidad de que los datos se expresen de diferentes maneras, existe una elevada probabilidad de que así sea. Aunque predomine una determinada forma, vamos a decir que canónica, es altamente probable que también se empleen otras formas que no lo son tanto. Un buen ejemplo de ello lo tienes en la tabla que sigue.

Como puedes ver en este ejemplo ficticio, que no lo es en este aspecto de la variabilidad de expresión de la fecha, junto a la forma canónica (círculo naranja), nos encontramos con otras forma diferentes (por ejemplo, pero no sólo, la marcada con el círculo verde). Aunque para un análisis visual de los datos esto no supone ningún problema (salvo en casos muy concretos), para el tratamiento automatizado de los datos se convierte en un serio problema: esas fecha no-canónicas no son reconocidas como tales fechas. Para evitar la pérdida de datos que esto implica, es necesario convertir estos datos al formato fecha. Podemos hacerlo manualmente, con ayuda de las funciones de formato de Calc (o Excel), aunque esto nos llevará tiempo. También contamos con la opción de automatizar el reajuste de formato mediante un script Python como el que sigue.



# 0. Bibliotecas y módulos ---

import pandas as pd
import re
from datetime import datetime

# 1. Funciones ---

# 1.a Función secundaria. Transforma string en fechas ---

def normalizar_fecha(texto):

    if pd.isna(texto) or str(texto).strip() == "":
        return None
    
    dato = str(texto).lower().strip()
    
    meses_map = {
        'enero': 1, 'febrero': 2, 'marzo': 3, 'abril': 4, 'mayo': 5, 'junio': 6,
        'julio': 7, 'agosto': 8, 'septiembre': 9, 'octubre': 10, 'noviembre': 11, 'diciembre': 12
    }

    try:
        return pd.to_datetime(dato, dayfirst=True).date()     # Intento 1: Formato numérico completo (ej. 2/02/2026)
    except:
        pass
    anio_detectado = re.search(r'(\d{4})', dato)               # Intento 2: Formato parcial (ej. enero 2014)
    if anio_detectado:
        anio = int(anio_detectado.group(1))
        for mes_nombre, mes_numero in meses_map.items():
            if mes_nombre in dato:
                return datetime(anio, mes_numero, 1).date()      # Retornamos siempre el día 1 del mes encontrado
    return None                                                  # Si llega aquí, no se pudo convertir y devolvemos None

# 1.b Función principal ---

def procesar_limpieza(ruta_entrada, columna_objetivo, ruta_salida):
    
    try:
        print(f"Cargando archivo: {ruta_entrada}...")
        df = pd.read_csv(ruta_entrada, sep=';', encoding='utf-8')
        df['fecha_corregida'] = df[columna_objetivo].apply(normalizar_fecha)                # Aplicamos la normalización (funcion 1a)
        df['fecha_corregida'] = pd.to_datetime(df['fecha_corregida'], errors='coerce')      # Convertimos a datetime Pandas (AAAA-MM-DD)
        df.to_csv(ruta_salida, sep=';', index=False, encoding='utf-8')                      # Exportamos el resultado (csv)
        print(f"Éxito: Archivo '{ruta_salida}' generado con fechas estandarizadas.")
        
    except Exception as e:
        print(f"Error durante el proceso de código: {e}")

# 2. Script principal (Llamada a la función principal) ---

if __name__ == "__main__":

    archivo_origen = r''            # Aquí la ruta del archivo csv de trabajo
    columna_fechas = 'fecha'        # Aquí el nombre de la columna a procesar
    archivo_destino = r''      		# Aquí el nombre del csv resultante
    
    procesar_limpieza(archivo_origen, columna_fechas, archivo_destino) # Llamada a la función


La estructura del script es relativamente simple: dos funciones y un script. El script llama a la función principal pasando parámetros (procesar_limpieza(archivo_origen, columna_fechas, archivo_destino)) que el profesional asigna a variables (vg. archivo_origen = r'') y la función principal hace uso de la secundaria para tratar el contenido textual del campo y devolver un formato fecha del módulo datetime en formato Pandas (AAA-MM-DD).

La función principal es sumamente importante: (1) Carga el csv de trabajo, (2) Llama a la función secundaria para normalizar el formato (3) convierte el dato devuelto por esa función al formato datetime de Pandas y (4) genera el scv de salida.

  1. df = pd.read_csv(ruta_entrada, sep=';', encoding='utf-8')
  2. df['fecha_corregida'] = df[columna_objetivo].apply(normalizar_fecha)
  3. df['fecha_corregida'] = pd.to_datetime(df['fecha_corregida'], errors='coerce')
  4. df.to_csv(ruta_salida, sep=';', index=False, encoding='utf-8')

Pero la función secundaria no es menos interesante, como veremos a continuación. Recordemos:



def normalizar_fecha(texto):

    if pd.isna(texto) or str(texto).strip() == "":
        return None
    
    dato = str(texto).lower().strip()
    
    meses_map = {
        'enero': 1, 'febrero': 2, 'marzo': 3, 'abril': 4, 'mayo': 5, 'junio': 6,
        'julio': 7, 'agosto': 8, 'septiembre': 9, 'octubre': 10, 'noviembre': 11, 'diciembre': 12
    }

    try:
        return pd.to_datetime(dato, dayfirst=True).date()     # Intento 1: Formato numérico completo (ej. 2/02/2026)
    except:
        pass
    anio_detectado = re.search(r'(\d{4})', dato)               # Intento 2: Formato parcial (ej. enero 2014)
    if anio_detectado:
        anio = int(anio_detectado.group(1))
        for mes_nombre, mes_numero in meses_map.items():
            if mes_nombre in dato:
                return datetime(anio, mes_numero, 1).date()      # Retornamos siempre el día 1 del mes encontrado
    return None                                                  # Si llega aquí, no se pudo convertir y devolvemos None



En esta función podemos diferenciar varias partes:
  • Primero un procedimiento de control para cuando la variable texto está vacía o no es válida
  • A continuación la variable texto se convierte a str y se normaliza a minúscula, pasando esta doble conversión a la varible dato
  • Después construímos una diccionario de meses:numerales meses_map para capturar el contenido del campo que procesamos y que a esta función hemos pasado como parámetro def normalizar_fecha(texto)

Lo que viene a continuación es el núcleo central de la función, que se desarrolla dentro de una estructura de control de excepciones try...except, en la que la parte try controla la presencia en dato de la formulación de la fecha según el formato dd/mm/aaaa y la transformar una entrada un objeto de fecha puro Año-Mes-Día de la librería Pandas. La parte except controla la posible comisión de errores

Si la primera solución no resolvió el procesamiento de la variable, la segunda utiliza la expresión regular re.search(r'(\d{4})', dato) para localizar y obtener el dato año (4 dígitos dentro del texto) y si lo encuentra recorre el listado de meses para ver si el término nombre del mes está escrito en dato. Si lo está, sobre el dato año y la conversión numérica del dato mes se construye una fecha, usando 1 como dato día.

Con este script hemos normalizado reformulandolo el campo fecha para que sea posible trabajar con esta variable en la fase de análisis de datos. El resultado es una nueva columna o campo (fecha_corregida). Cierto que que no todos los datos se han podido modificar según lo esperado debido a que el dato original no se ajusta a las formas esperadas. esto puede dar lugar a una nueva pérdida de datos, aunque al ser una circunstancia controlada y de relativa poca importancia, también podemos optar por realizar una última revisión "manual" a fin de evitar una pérdida de datos innecesaria y no deseada. Llegados a este punto, no es una mala solución.

sábado, 9 de mayo de 2026

DATOS. Tratamiento de datos

Limpieza de datos (I)

Datos faltantes

Es muy frecuente que en una colección de datos de cierta entidad se observen campos en blanco o con una marca sustituta (ver tabla abajo), a consecuencia de diversos motivos, incluídos los errores de recogida de datos y la simple ausencia de éstos por no producirse determinada respuesta. Lo que en realidad importa para su tratamiento posterior es que las ausencias sean razonablemente escasas; en caso contrario, la calidad del conjunto de datos se ve muy seriamente comprometida.

Entorno Elemento
Python puro None
Data Science (Pandas/NumPy) NaN
Base de datos (SQL) NULL
Lenguaje R NA

Las opciones disponibles para hacer frente a esta realidad son varias, desde la eliminación del registro hasta el relleno del campo con determinado valor, resultante de algún tipo de cálculo. Para conocer cuales son esas alternativas, además de consultar a la IA, que lo que hace es resumir el conjunto de posibilidades disponibles, sustituyendo así a una simple búsqueda web, también podemos consultar alguna página específicamente pensada para tratar este tema. Yo aquí te ofrezco un ejemplo de la segunda opción; la primera corre de tu cuenta.

No es por comodidad, pero en este caso voy a optar por la forma más simple de resolver el problema: eliminando el registro. No es precisamente la mejor solución, especialmente cuando no disponemos de muchos registros, pero sí la más adecuada para un caso como el que nos ocupa en el que no hay la pretensión de representatividad estadística ni interés por crear un procedimiento de (aprendizaje automático (AA). Además, en este caso, en la mayoría de los registros faltan todos los datos que pueden faltar, dado que son resultado de una opción de tratamiento del documento que ahora no me intersa comentar; sirva con decir que las tablas simplemente no están cumplimentadas, por lo que es hasta coherente eliminar estos registros. Puede que no lo sea tanto en los casos en que sólo falta la fecha, pero son muy escasos y el riesgo real de pérdida de información es mínimo.



import pandas as pd
import os
import numpy as np

# --- Función ---

def limpiar_tabla_especifica(ruta_absoluta):
    if not os.path.exists(ruta_absoluta):
        print(f"Error: No se localiza el archivo en {ruta_absoluta}")
        return

    try:
        df = pd.read_csv(ruta_absoluta, sep=';', engine='python')   # 1. Carga los datos con el separador identificado (sep=';')
        total_inicial = len(df)                                     # Conteo inicial para calcular los registros afectados

        df = df.replace(r'^\s*$', np.nan, regex=True)               # 2. Convertir espacios vacíos (" ") en nulos reales (NaN)

        columnas_a_validar = ['fecha', 'SEO', 'OE']                 # 3. Eliminar registros si falta dato en fecha-SEO-OE
        df_limpio = df.dropna(subset=columnas_a_validar)

        total_final = len(df_limpio)                                # 4. Cálcular los registros eliminados
        eliminados = total_inicial - total_final

        ruta_directorio = os.path.dirname(ruta_absoluta)            # 5. Exportar el resultado a csv
        nombre_salida = "lista_t1_sin_nulos.csv"
        ruta_salida = os.path.join(ruta_directorio, nombre_salida)

        df_limpio.to_csv(ruta_salida, sep=';', index=False)

        print("-" * 30)                                              # 6. Informe a mostrar por consola
        print("INFORME DE PROCESAMIENTO")
        print("-" * 30)
        print(f"Registros analizados: {total_inicial}")
        print(f"Registros eliminados (faltaba fecha, SEO o OE): {eliminados}")
        print(f"Registros válidos restantes: {total_final}")
        print(f"Archivo generado: {ruta_salida}")

    except Exception as e:
        print(f"Se produjo un error durante la ejecución: {e}")

# --- Llamada a la función ---

ruta_csv = r"" 														# Aqui la ruta de tu archivo csv
limpiar_tabla_especifica(ruta_csv)                                  # Llamada a la función


Mediante este script eliminamos los registros que carecen de datos en las columnas (variables) de interés columnas_a_validar = ['fecha', 'SEO', 'OE'], cosa que sucede gracias al uso de la función df.dropna() de Pandas (df_limpio = df.dropna(subset=columnas_a_validar)). Al especificar el identificador de las columnas hacemos que el script sea dependiente de la tabla, a la vez que nos permite identificar lo que deberemos cambiar si cambiamos de tabla.

Y hablando de especificidades, debo decir que para que el script funcione ha sido necesario especificar el separador de campos de la tabla (df = pd.read_csv(ruta_absoluta, sep=';', engine='python')), ya que el csv-base utiliza ; en vez del esperado (,) por defecto por Pandas.

El resultado tiene una doble expresión: archivo como csv (comentario 5 del script) se muestra una síntesis por consola (comentario 6). No proporciono ninguna prueba por innecesaria; es suficiente con que pienses en la última tabla de esta entrada sin el registro INF_003.dot.

viernes, 8 de mayo de 2026

DATOS. Tratamiento de datos

Organización de datos

Datos semi-estructurados

Cuando trabajamos con tablas-formulario (en documentos .odt en nuestro caso, aunque podrían estar ubicadas en otro tipos de documentos) fuimos capaces de extraer todos los datos que contenían, pero, como quedó dicho en ese momento, obtenemos tanto las etiquetas (identificadores de los campos) como los datos propiamente dicho. Esto conlleva que las tablas no sean directamente utilizables dentro de un planteamiento de automatización de ese uso, ya que estaríamos considerando una etiqueta como dato. Por ello es necesario reorganizarlas, dando a cada contenido su función específica, lo que equivale a convertir a estos datos de semi-estructurados a estructurados.

Para concretar lo que significa esta reorganización de los datos, vamos a trabajar con una tabla resultante del procedimiento de extracción desarrollado en entradas precedentes (por ejemplo). Por motivos que entenderás óbvios, me limitaré a mostrar únicamente unos pocos registros anonimizados para que quede claro cómo se presenta la tabla inicialmente y cómo debería quedar al finalizar el procedimiento. En este punto, ese procedimiento ha sido realizado manualmente, pero la idea es que se ejecute de forma automatizada, mediante un script Python.

Pero recordemos antes el origen de los datos:

... para visualizar a continuación la tabla que deberemos reorganizar:

Detengámonos un momento en analizar ambas imágenes (que es lo que son, capturas de pantalla modificadas) para concretar lo que dijimos al inicio de esta entrada: aunque capturamos integralmente el contenido de la tabla de formacorrecta, el resultado (precisamente por ello) no es directamente utilizable o, lo que es lo mismo, estos datos se presentan como datos estructurados (filas-columnas) pero no están debidamente organizados ni limpios. Pero por ahora nos centraremos en la primera cuestión, que tiempo habrá para tratar la segunda.

Las tres primeras columnas son directamente válidas, pero las que restan necesitan ser reorganizadas. En otro tipo de tablas, es posible que ni siquuera pasáramos de las dos primeras, y eso porque han sido generadas mediante el script, al margen de la recuperación de datos propiamente dicha. Es por eso que, en esta tabla, todo lo que no son datos identicativos del registro es necesario mapearlo para reconocer su naturaleza como etiquetas o como datos. Pero para hacer esto debemos fijarnos en dos cuestiones: la variedad de estructuras que presenta la tabla y las etiquetas que haya generado el script.

No existe una única estructura aunque visualamente no observemos, en algunos casos, esas diferencias. La tabla-formulario que mostramos (informe, no dictamen, por ajustarse a los datos obtenidos que mostramos) sólo permite identificar 6 celdas y una estructura de 2x3, pero en la tabla resultante comprobamos primero que existe una diversidad de estructuras (2x4, 2x3, 3x3) con un predomino de una de ellas (2x4), que es lo que explica las etiquetas (o campos): A1 - B1 - D1 - A2 - B2 - C2. Al ser la estructura predominante es a la que se adapta la tabla resultante; en ella no se recoge la celda B1, pero tampoco la celda D2, como sería de esperar según la diversidad de estructuras. Esto facilita, en términos generales, la reorganizar la tabla, ya que deberemos ajustarnos a los identificadores y no a la diversidad de estructuras detectadas, pero tampoco a lo que visualmente observamos, sino a lo que debemos inferir en función de las etiquetas establecidas por el script: C1 es asimilable a D1, lo que resuelve la reorganización de la mayoría de los registros (tipos 2x3 y 2x4), aunque es posible que algunos de los que se ajustan a la estructura 3x3 deberán ser objeto de una atención específica. Esta estructura responde a tablas en las que se diferencia la intervención de al menos dos profesionales, quedando el segundo fuera del esquema de la tabla. Realmente no se contempló en el procedimiento de reorganización que aquí se expone, lo que implica que éste deberá desarrollarse en al menos dos fases.

Con todos esos conocimientos, el profesional que realiza la reestructuración de la tabla, si lo hace manualmente (cosa que aquí no es especialmente costoso), deberá identificar qué campos (columnas) contienen etiquetas [A1(C) - B1(D) - B2(G)] y cuáles datos [D1(E) - A2(F) -C2(H)]. LO que resta es reformular las etiquetas que corresponden y reubicar esos bloques de forma lógica, según se espera en una colección (tabla) de datos estructurados. Este sería el resultado (lo que se aprecia en el documento INF_003 corresponde a la realidad: ese documento carece de datos):

Si queremos automatizar este mismo procedimiento, el script que sigue nos permitirá hacerlo para los registros cuya estructura se ajuste a los modelos 2x4 y 2x3, pero no para los esquemas 3x3.



import csv
import os
from datetime import datetime

# --- Función secundaria 1. Registro de errores ---

def registrar_error_log(ruta_log, nombre_fichero, mensaje_error):

    fecha_hora = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    try:
        with open(ruta_log, mode='a', encoding='utf-8') as f_log:
            f_log.write(f"[{fecha_hora}] FICHERO: {nombre_fichero} | ERROR: {mensaje_error}\n")
    except Exception as e:
        print(f"Error crítico: No se pudo escribir en el log de errores: {e}")

# --- Función secundaria 2. Reestructuración de BD (csv)

def registrar_en_csv_rev(ruta_salida, datos_mapeados):

    campos = ['ARCHIVO_ORIGEN', 'ESTRUCTURA', 'fecha', 'SEO', 'OE']
    archivo_existe = os.path.exists(ruta_salida)
    
    try:
        with open(ruta_salida, mode='a', encoding='utf-8', newline='') as f_rev:
            escritor = csv.DictWriter(f_rev, fieldnames=campos, delimiter=';')
            if not archivo_existe:
                escritor.writeheader()
            escritor.writerow(datos_mapeados)
        return True, None
    except Exception as e:
        return False, str(e)

# --- Función principal ---

def ejecutar_procesamiento_batch(ruta_csv, filtro_nombre, filtro_estruc):
    
    if not os.path.exists(ruta_csv):
        print(f"Error: No se encuentra el archivo maestro en {ruta_csv}")
        return

    dir_name = os.path.dirname(ruta_csv)                                                # Rutas de salida
    base_name = os.path.splitext(os.path.basename(ruta_csv))[0]
    ruta_rev = os.path.join(dir_name, f"{base_name}_REV.csv")
    ruta_log = os.path.join(dir_name, f"{base_name}_ERRORES.txt")

    try:
        with open(ruta_csv, mode='r', encoding='utf-8-sig') as f:
            muestra = f.read(2048)
            dialecto = csv.Sniffer().sniff(muestra)
            f.seek(0)
            
            lector = csv.DictReader(f, dialect=dialecto)
            
            print(f"--- INICIANDO PROCESAMIENTO AUTOMÁTICO ---")
            
            for fila in lector:
                orig = (fila.get('ARCHIVO_ORIGEN') or "DESCONOCIDO").strip()
                tipo = (fila.get('ESTRUCTURA') or "").strip()

                if filtro_nombre in orig and tipo == filtro_estruc:
                    registro_rev = {                                        # Mapeo de datos
                        'ARCHIVO_ORIGEN': orig,
                        'ESTRUCTURA': tipo,                                 # Datos ajustados para tabla 3x3
                        'fecha': (fila.get('A2') or "").strip(),            #  'fecha': (fila.get('C2') or "").strip()
                        'SEO': (fila.get('D1') or "").strip(),              #  'SEO': (fila.get('D1') or "").strip()
                        'OE': (fila.get('C2') or "").strip()                #  'OE': (fila.get('B2') or "").strip()
                    }
                    
                    exito, error_msg = registrar_en_csv_rev(ruta_rev, registro_rev)     # Proceso de guardado (función secundaria 2)
                    
                    if exito:
                        print(f"[OK] {orig} procesado correctamente.")
                    else:
                        print(f"[AVISO] Fallo en {orig}. Registrando en log...")
                        registrar_error_log(ruta_log, orig, error_msg)				# Llamada (condicionada) a la función secundaria 1

            print(f"--- PROCESAMIENTO FINALIZADO ---")

    except Exception as e:
        print(f"Error general en la lectura del CSV maestro: {e}")

# --- Llamada a la función principal ---

if __name__ == "__main__":
    
    RUTA_SISTEMA = r"ruta_de_mi_archivo.csv"           # Aquí la ruta absoluta (incluido nombre) de la tabla CSV a reestructurar
    TIPO_DOC = "INF"                                   # Aquí el identificador del tipo de archivo (DE vs. INF)
    ESTRUCTURA = "2x4"                                 # Aquí el identificador del tipo de estructura (2x3, 2x4)
    
    ejecutar_procesamiento_batch(RUTA_SISTEMA, TIPO_DOC, ESTRUCTURA)


El resultado para los registros 2x3 y 2x4 es el positivo, ya que el script de extracción previo asimiló las celdas, según expliqué en el párrafo anterior a la presentación del script, así que la tabla de reformulación automatizada de la estructura es la misma que la resultante del procedimiento manual. Así que nos ahorramos repetirla.

Por explicar el script, decir que, como se puede ver, está compuesto por tres funciones, dos secundarias y la principal, y el ejecutor de la función princial. Empezando por éste, se diferencian en él las variables que el usuario deberá adaptar a su caso (siempre con la limitación de la tabla original, tanto de los documentos de informe como de dictamen de los modelos asturianos): ruta del csv a reformular, identificador del tipo de documento e identificador del modelo de tabla. Este script llama a la función principal pasando los datos anteriores como parámetros y esta función, además de ejecutar su cometido, llama a las funciones secundarias o auxiliares (ver los comentarios). Destacar en esta función principal el segmento de código encargado del mapeo de los datos, entre otras cosas, porque es el que deberemos adaptar a nuestras necesidades, por ejemplo para procesar los registro 3x3 (en comentarios se concreta esa alternativa)

La función secundaria 1 se activa de forma condicionada, por lo que no necesariamente se ejecuta. Tiene como objetivo crear un regisgtro (txt) de errores en el proceso, pero no he observado que se ejecute. La que sí se ejecuta (afortunadamente) es la función secundaria 2ª, ya que su comentido es crear el archivo csv de reestructuración de la tabla.

miércoles, 6 de mayo de 2026

DATOS. Archivos de texto

Archivos de texto

Acceso a contenidos específicos

No tengo muy claro si esta entrada debe situarse en esta subsección (Acceso a datos) o si debería ubicarse en la que sigue (Limpieza de datos), entendida en sentido amplio, pero posiblemente esto no es lo más importante. Lo que interesa es el concepto que se trata en ella y lo que implica. Antes de decir algo más al respecto, es necesario advertir que, en cualquier caso, ahora sólo trataremos estas cuestiones en términos muy básicos y limitados, ya que será necesario retomar el tema cuando hablemos de organización y limpieza de los datos no estructurados y, como no puede ser de otro modo, también en la subsección Uso de datos.

En las entradas anteriores hemos estado tratando cuestiones relativas al acceso a los datos de los textos como un todo, diferenciando si este contenido se encuentra ubicado en tablas o si conforma párrafos más o menos extensos. Esta diferención lo es también de datos vs. textos, o si se preferie (y yo sí lo prefiero) entre datos semi-estructurados frente a datos no estructurados. En sentido estricto, ningun documento contiene datos estructurados, así que lo más parecido a éstos son los datos semi-estructurados que encontramos en las tablas y en las tablas-formulario de los documentos. Pero ahora nos hemos alejado de estos entornos y nos hemos enfrentado al texto en sentido estricto, lo que equivale a decir a los datos no estructurados; y ante estos el mero acceso (y "captura") sólo puede ser una primera (y muy necesaria) fase de un proceso considerablemente complejo que requiere varios subprocesos. Lo que ahora abordamos sería un intento de concretar una opción de posible segunda fase.

Y es que de poco sirve capturar un texto si no obtenemos información de él. Pasa lo mismo cuando hablamos del tratamiento analógico de los textos, pero es mucho más definitorio (y difícil de llevar a cabo) cuando el tratamiento es digital. Podemos concretarlo de muchas formas, así que la que ahora voy a proponer es sólo una de ellas, y no precisamente de las de mayor complejidad.

En el marco del acceso al contenido de los textos de una colección de informes psicopedagógicos, deseamos identificar si en ellos se hace alguna mención al uso del test (batería) WISC. Esto implica interés por saber si este recurso se ha empleado, con qué peso cuantitativo en la muestra documental y qué información se asocia con ese uso.

El objetivo simple, identificar uso vs. no-uso del WISC, no parece ofrecer mayor dificultad, pero acceder al contenido textual asociado a esta etiqueta conlleva cierto nivel de complejidad, que nos proponemos abordar de la forma más directa y simple posible, lo que no garantiza que sea la mejor, aunque según cual sea nuestro objetivo puede ser suficiente. Sí lo es para el objetivo que nos planteamos en esta entrada.



import os
import csv
from odf.opendocument import load
from odf import text

# --- Función secundaria 1: Extracción lineal de párrafos ignorando tablas. ---

def extraer_parrafos_no_tablas(ruta_archivo):

    try:
        doc = load(ruta_archivo)
        parrafos_limpios = []
        for p in doc.getElementsByType(text.P):
            parent = p.parentNode					 # Filtro de tablas por ancestros
            dentro_de_tabla = False
            while parent is not None:
                if parent.tagName == "table:table":
                    dentro_de_tabla = True
                    break
                parent = parent.parentNode
            
            if not dentro_de_tabla:
                contenido = "".join(node.data for node in p.childNodes if node.nodeType == 3)	 # Unimos todos los nodos de texto
                if contenido.strip():
                    parrafos_limpios.append(contenido.strip())
        return parrafos_limpios
    except Exception as e:
        print(f"   [!] Error de lectura en {os.path.basename(ruta_archivo)}: {e}")
        return []

# --- Función secundaria 2: Captura el párrafo del hallazgo y los N siguientes para asegurar el contexto. . ---

def extraer_segmento_contiguo(lista_parrafos, termino, num_posteriores=4):
   
    segmentos_completos = []
    indices_ya_incluidos = set()

    for i, texto in enumerate(lista_parrafos):
        if termino.lower() in texto.lower() and i not in indices_ya_incluidos:
            rango_fin = min(i + num_posteriores + 1, len(lista_parrafos))		# Iniciamos bloque
            bloque = lista_parrafos[i:rango_fin]
            
            for j in range(i, rango_fin):		 # Marcamos para no repetir si el término aparece en los párrafos de este bloque
                indices_ya_incluidos.add(j)
            
            segmentos_completos.append("\n\n".join(bloque))
            
    return segmentos_completos

# --- Función principal ---

def procesar_informes(dir_entrada, csv_salida, palabra_clave):
    if not os.path.exists(dir_entrada):
        print(f"La ruta {dir_entrada} no existe.")
        return

    archivos = [f for f in os.listdir(dir_entrada) if f.lower().endswith('.odt')]
    resultados = []

    print(f"Procesando {len(archivos)} archivos...\n")

    for nombre in archivos:
        ruta = os.path.join(dir_entrada, nombre)
        parrafos = extraer_parrafos_no_tablas(ruta)		# Llamada a función secundaria 1
        
        hallazgos = extraer_segmento_contiguo(parrafos, palabra_clave, num_posteriores=4)	 # Llamada a función secundaria 2
        
        estado = "PRESENTE" if hallazgos else "AUSENTE"
        print(f"[{estado}] {nombre}")
        
        resultados.append({
            'Archivo': nombre,
            'Hallazgo': estado,
            'Texto': " |SECCIÓN SIGUIENTE| ".join(hallazgos) if hallazgos else "N/A"
        })

    try:
        with open(csv_salida, mode='w', newline='', encoding='utf-8') as f:
            writer = csv.DictWriter(f, fieldnames=['Archivo', 'Hallazgo', 'Texto'])
            writer.writeheader()
            writer.writerows(resultados)
        print(f"\nCSV generado con éxito en: {csv_salida}")
    except Exception as e:
        print(f"Error al escribir CSV: {e}")

# --- Llamada a función ---

if __name__ == "__main__":
    RUTA_ARCHIVOS = r"ruta_de_directiro_de_documentos"		# Ruta de acceso a la colección de documento
    RUTA_CSV = r"ruta_destino_y_nombre_archivo.csv"			# Ruta para ubicación de csv e identificador del archivo
    TERMINO = "WISC"										# Término objeto de estudio

    procesar_informes(RUTA_ARCHIVOS, RUTA_CSV, TERMINO)


Lo que hacemos con este script es, en primer lugar prescindir de las tablas del documento y de los datos que éstas contienen para centrarnos en la extracción de los textos (párrafos). Dentro de ellos, buscamos la aparición del término-diana (aquí WISC) y lo relacionamos con su contexto (texto inmediatamente próximo al término-diana) para capturar el contenido que se asocia al mismo- Esto nos permite acceder no sólo a información sobre la presencia vs. ausencia del término-diana en el documento, sino también al contenido asociado a dicho término, lo que supone una mayor complejidad del procedmiento de automatización, pero también que el script es capaz de facilitar información que incrementa significativamente nuestra capacidad de análisis de los documentos. No estamos en condiciones para asegurar que esto sea suficiente para objetivos complejos, pero sí para muchos otros más simples. En todo caso sólo estamos ante un procedimiento básico. En otras subsecciones trataremos cómo desarrollar otras.

Presento también el primer script de una serie de tres (el anterior es el tercero) como punto de referencia para comprender la línea de desarrollo que se pretende explictar y que se concreta fundamentalmente como incremento del contexto textual que rodea al término-diana.



import os
import csv
from odf.opendocument import load
from odf import text

#--- Función secundaria. Filtar tablas ---

def extraer_parrafos_no_tablas(ruta_archivo):
    try:
        doc = load(ruta_archivo)
        parrafos_validos = []
        for p in doc.getElementsByType(text.P):
            dentro_de_tabla = False
            parent = p.parentNode
            while parent is not None:
                if parent.tagName == "table:table":
                    dentro_de_tabla = True
                    break
                parent = parent.parentNode
            if not dentro_de_tabla:
                contenido = "".join(node.data for node in p.childNodes if node.nodeType == 3)
                if contenido.strip():
                    parrafos_validos.append(contenido.strip())
        return parrafos_validos
    except Exception as e:
        print(f"Error al leer el archivo {ruta_archivo}: {e}")
        return []
        
# --- Función principal --- 

def procesar_coleccion_odt(directorio_entrada, ruta_csv, termino):
    print(f"Buscando en: {directorio_entrada}...")
    
    if not os.path.exists(directorio_entrada):
        print(f"ERROR: La carpeta no existe: {directorio_entrada}")
        return

    archivos = [f for f in os.listdir(directorio_entrada) if f.lower().endswith('.odt')]
    print(f"Se han encontrado {len(archivos)} archivos .odt")

    if len(archivos) == 0:
        print("No hay nada que procesar. Revisa la extensión de los archivos.")
        return

    resultados = []
    for nombre_archivo in archivos:
        ruta_completa = os.path.join(directorio_entrada, nombre_archivo)
        parrafos = extraer_parrafos_no_tablas(ruta_completa)
        hallazgos = [p for p in parrafos if termino.lower() in p.lower()]
        
        presencia = "PRESENTE" if hallazgos else "AUSENTE"
        print(f"Procesado: {nombre_archivo} -> {presencia}")
        
        resultados.append({
            'Nombre del Archivo': nombre_archivo,
            'Presencia Termino': presencia,
            'Contenido Extraído': " | ".join(hallazgos) if hallazgos else "N/A"
        })

    try:
        with open(ruta_csv, mode='w', newline='', encoding='utf-8') as f:
            writer = csv.DictWriter(f, fieldnames=['Nombre del Archivo', 'Presencia Termino', 'Contenido Extraído'])
            writer.writeheader()
            writer.writerows(resultados)
        print(f"\nÉXITO: Archivo creado en {ruta_csv}")
    except Exception as e:
        print(f"ERROR al crear el CSV: {e}")

# --- LLAMADA AL SCRIPT ---

if __name__ == "__main__":
    RUTA_DOCS = r"ruta_archivos_a_procesar"				 # Definición de variables de configuración
    RUTA_OUT  = r"ruta_del_archivo.csv"
    OBJETIVO  = "WISC"

    procesar_coleccion_odt(RUTA_DOCS, RUTA_OUT, OBJETIVO)
    

Para comprender lo que conseguimios con estos archivos, nada mejor que un breve estudio de resultados, aunque sin ninguna pretensión de representatividad; tan sólo a nivel de indicio. Analizo a continuación los 14 primeros archivos de una colección significativamente más amplia, pero que ahora no interesa ya que en esta entrada no pretendo realizar ningún estudio (cosa que puede quedar pendiente para otro momento).

De estos 14 archivos, sólo en tres se identifica el término-diana (WISC), lo que ahora es prácticamente irrelevante, pero que dejaría de serlo en un estudio orientado a valorar el grado de uso de un determinado recurso como es la escala WISC en sus diferentes versiones. Lo que sí es relevante ahora es comprender las diferencias que se aprecian en términos de recuperación de la información asociada al término-diana entre el primero y el tercero de los script que se desarrollaron para logar esta meta. De paso, es necesario destacar que existe una diferencia importante entre este modelo de trabajo y lo que sería la mera identificación de la presencia del término. Veamos esa evolución:

Vemos que existen notables diferencias entre los tres archivo, las cuales se deben fundamentalmente al peso real de los datos asociados al término-diana en cada uno de los documento (otra parte se puede entender asociada al mayor o menos éxito del procedimiento de obtención de datos); pero lo que resulta más importante es la evolución entre el primer y el tercer script, que en este gráfico se presenta en términos absolutos (número de palabras por segmento textual y versión del script.

Si lo expresamos en términos porcentuales, algunos datos pueden contribuir a la mejor comprensión del resultado:

  1. Salvo en el primer documento, en los otros dos el incremento obedece al esquema [Tv1 menor que Tv2 menor que Tv3]. En ese primer documento, se aprecia una ligera disminución del número de palabras, pero también es ese documento el que mayor incremento presenta entre el Tv1 y Tv2 (concretamente algo más del 466%)
  2. Esto quiere decir que el script que mejores resultados ofrece (como cabe esperar) el tercero (salvo para el primer documento, que es el segundo script).
  3. La ganancia que se obtiene con este tercer script respecto al primero es evidente: más del 400% en el documento de menos impacto, pero por encima del 700% en los otros dos, concretamente el 788% en uno y algo más del 900% en el otro.

Dado que no se trata de un estudio con intención de ser representativo, estos datos no se deben extrapolar, pero son suficientemente reveladores que lo que interesa mostrar aquí: podemos desarrollar procedimientos que facilitan el acceso selectivo a datos relativos a determinado término, aunque faltan datos que permitan identificar con propiedad la calidad de estos resultados. También esta importante cuestión queda para un análisis específico y para el desarrollo de otros procedimientos alternativos.

DATOS. Archivos de texto

Archivos .odt

Acceso a tablas y textos

Aunque no aporte mucho a lo que ya sabemos, no podemos dejar de exponer los procedimientos que nos permiten acceder al contenido de los archivos .odt, dada la frecuencia con la que trabajamos con estos archivos en este blog. Así vamos a replicar aquí lo que ya hicimos en esta entrada con los archivos .docx.



import os
import sys
import io
from odf import text, table, teletype
from odf.opendocument import load

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

# --- Función ----

def leer_contenido_odt(ruta_archivo):
    if not os.path.exists(ruta_archivo):				    # Controla que existe el archivo
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return

    try:
        doc = load(ruta_archivo)				    		# Cargar el documento ODT
    except Exception as e:
        print(f"Error al cargar el archivo: {e}")
        return
    
    print(f"--- INICIO DEL CONTENIDO: {ruta_archivo} ---\n")

    contenido_principal = doc.text				 		 # Accedemos al texto

    for elemento in contenido_principal.childNodes:
        
        if elemento.tagName in ["text:p", "text:h"]:		# 1. Párrafos y Encabezados (h = heading, p = paragraph)
            texto = teletype.extractText(elemento).strip()
            if texto:
                etiqueta = "TÍTULO" if elemento.tagName == "text:h" else "PÁRRAFO"  # Diferenciamos si es título o párrafo
                print(f"[{etiqueta}]: {texto}")
        elif elemento.tagName == "table:table":				# 2. Tablas
            filas = elemento.getElementsByType(table.TableRow)
            nombre_tabla = elemento.getAttribute("name") or "Tabla"
            
            print(f"\n[--- INICIO {nombre_tabla} ---]")
            for fila in filas:
                celdas = fila.getElementsByType(table.TableCell)					# Extraemos el texto de cada celda
                contenido_fila = [teletype.extractText(celda).strip() for celda in celdas]
                if any(contenido_fila):												# Solo imprimimos la fila si tiene algún contenido
                    print(" | ".join(contenido_fila))
            print(f"[--- FIN {nombre_tabla} ---]\n")

    print(f"\n--- FIN DEL DOCUMENTO ---")

# --- Llamada a función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_mi_archivo.odt"  # Aquí el nombre del archivo .odt
    leer_contenido_odt(nombre_archivo)			# Llamada a la función


Ahora toca identificar sólo las tablas...


  
import os
import sys
import io
from odf import table, teletype
from odf.opendocument import load

# Configuración para que la consola de Windows maneje correctamente caracteres especiales
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

# --- Función de extracción de tablas ---

def extraer_tablas_odt(ruta_archivo):

    if not os.path.exists(ruta_archivo):							# Nombre del archivo a procesar
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return
    
    try:															# Cargar el documento ODT
        doc = load(ruta_archivo)
    except Exception as e:
        print(f"Error al cargar el archivo: {e}")
        return
    
    print(f"--- EXTRACCIÓN DE TABLAS: {ruta_archivo} ---\n")

    # Buscamos todas las tablas en el documento mediante getElementsByType()
    tablas = doc.getElementsByType(table.Table)

    if not tablas:
        print("No se encontraron tablas en el documento.")
        return

    for i, t in enumerate(tablas):
        nombre_tabla = t.getAttribute("name") or f"Tabla {i+1}"
        print(f"[--- INICIO {nombre_tabla} ---]")
        
        filas = t.getElementsByType(table.TableRow)
        for fila in filas:
            celdas = fila.getElementsByType(table.TableCell)
            
            contenido_fila = [teletype.extractText(celda).strip() for celda in celdas]	# Extraemos el texto de cada celda
            
            if any(contenido_fila):														# Imprimimos la fila solo si contiene algún texto
                print(" | ".join(contenido_fila))
        
        print(f"[--- FIN {nombre_tabla} ---]\n")

    print(f"--- FINALIZADO ---")
    
# --- Llamada a la función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_mi_archivo.odt" 	# Aquí la ruta del archivo
    extraer_tablas_odt(nombre_archivo)			# Llamada a la función
    

Para finalizar vamos a mostrar el script que únicamente accede a los párrafos del documento, omitiendo el contenido de las tablas y de tablas-formulario.



import os
import sys
import io
from odf import text, teletype
from odf.opendocument import load

# Configuración para que la consola de Windows maneje correctamente caracteres especiales
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

# --- Función de extracción de texto (párrafos) ---

def leer_solo_parrafos_odt(ruta_archivo):
  
    if not os.path.exists(ruta_archivo):							 # Verificar si el archivo existe
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return

    try:
        doc = load(ruta_archivo)									# Cargar el documento ODT
    except Exception as e:
        print(f"Error al cargar el archivo: {e}")
        return
    
    print(f"--- CONTENIDO TEXTUAL (SIN TABLAS): {ruta_archivo} ---\n")

    contenido_principal = doc.text									# Accedemos al elemento de texto del cuerpo

    for elemento in contenido_principal.childNodes:					# Iteramos sobre los elementos del cuerpo
        
        if elemento.tagName in ["text:p", "text:h"]:				# Filtramos: Solo nos interesan párrafos (p) y encabezados (h)
            texto = teletype.extractText(elemento).strip()
            
            if texto:												# Solo imprimimos si el párrafo no está vacío
                # Opcional: puedes quitar la etiqueta [PÁRRAFO] si quieres el texto limpio
                etiqueta = "TÍTULO" if elemento.tagName == "text:h" else "PÁRRAFO"
                print(f"[{etiqueta}]: {texto}")

    print(f"\n--- FIN DEL DOCUMENTO ---")

# --- Llamada a la función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_mi_archivo.odt" 		# Aquí la ruta del archivo a trabajar 
    leer_solo_parrafos_odt(nombre_archivo)			# Llamada a la función


lunes, 4 de mayo de 2026

DATOS. Tratamiento de datos

Tablas complejas (.odt) (VI)

Optención automátizada de todos los datos de las tablas del documento

Con esta entrada finalizo la presentación de script de recuperación de datos semi-estructurados de tablas-formulario soportadas sobre archivos .odt. En este caso vamos a generar un procedimiento cuyo objetivo es obtener todos los datos de un conjunto de tablas del mismo documento, en teoría de todos los datos de todas las tablas, aunque en la práctica no es necesario y la mayoría de las veces puede que tampoco conveniente, abarcarlas todas.

Puede considerarse que este script es complementario del expuesto en la entrada anterior, y lo es en cuanto que ahora lo que pretendemos es obtener todos los datos de un único documento, mientras que antes lo que pretendíamos era obtener los mismos datos de muchos documentos diferentes. La cuestión es que este cambio de objetivo tiene varias implicaciones: en lugar de dirigir nuestros esfuerzos al estudio de una realida concreta y su manifestación colectiva en documentos individuales; ahora pretendemos recopilar exhaustivamete los datos de un único documento, aunque estos documentos comparten contener tablas-formulario y no tablas de datos en sentido estricto. De ahí que estemos hablando de datos semi-estructurados, frente a los datos estructurados que presentan las grandes (aunque no necesariamente, sí con frecuencia) bases de datos, como las que se manejan en la minería de datos y en la IA.

Una diferencia fácilmente observable que deriva del nuevo planteamiento es que antes el objetivo era obtener un archivo CSV por cada tabla que se analiza en la que cada registro (fila) es el contenido de dicha tabla en un documento; ahora vamos a generar un archivo .xlsx que contiene varias hojas, una por cada tabla del documento .odt. La elección de este soporte está justificada por nuestro interés por mantener todos los datos en un único archivo, y no tenerlos dispersos en varios, como obligaría el uso del formato csv. Esto facilita el manejo de los archivos, su consulta y posterior manipulación desde el servicio excel o Calc.



# --- 0. BIBLIOTECAS ---
import os
import pandas as pd
from odf.opendocument import load
from odf.table import Table, TableRow, TableCell
from odf import teletype

# --- 1. MOTOR DE LIMPIEZA ---
def limpiar_texto_odt(texto_bruto):
    if not texto_bruto: return ""
    texto = texto_bruto.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')
    return " ".join(texto.split()).lower()

# --- 2. TRADUCTOR DE COORDENADAS ---
def obtener_referencia_celda(fila_idx, col_idx):
    letras = ""
    temp_col = col_idx
    while temp_col >= 0:
        letras = chr(65 + (temp_col % 26)) + letras
        temp_col = (temp_col // 26) - 1
    return f"{letras}{fila_idx + 1}"

# --- 3. ANALIZADOR DE ESTRUCTURA (MEJORADO) ---
def extraer_datos_tabla(tabla):
    filas_xml = tabla.getElementsByType(TableRow)
    if not filas_xml: return [], [], "0x0"

    max_cols = 0
    for celda in filas_xml[0].getElementsByType(TableCell):
        span = int(celda.getAttribute("numbercolumnsspanned") or 1)
        max_cols += span
    
    total_filas = len(filas_xml)
    # MODIFICACIÓN (1): Generar el dato de estructura
    tipo_tabla = f"{total_filas}x{max_cols}"
    
    ocupada = [[False for _ in range(max_cols)] for _ in range(total_filas)]
    ids, contenidos = [], []

    for r_idx, fila in enumerate(filas_xml):
        celdas_xml = fila.getElementsByType(TableCell)
        c_xml_cursor = 0 
        for c_idx in range(max_cols):
            if ocupada[r_idx][c_idx]: continue
            if c_xml_cursor < len(celdas_xml):
                celda_actual = celdas_xml[c_xml_cursor]
                c_span = int(celda_actual.getAttribute("numbercolumnsspanned") or 1)
                r_span = int(celda_actual.getAttribute("numberrowsspanned") or 1)
                
                texto = teletype.extractText(celda_actual).strip()
                ids.append(obtener_referencia_celda(r_idx, c_idx))
                contenidos.append(texto)
                
                for i in range(r_span):
                    for j in range(c_span):
                        if r_idx + i < total_filas and c_idx + j < max_cols:
                            ocupada[r_idx + i][c_idx + j] = True
                c_xml_cursor += 1
    return ids, contenidos, tipo_tabla

# --- 4. FUNCIÓN MAESTRA EVOLUCIONADA ---
def ejecutar_extraccion_a_excel(ruta_odt, mapa_claves):
    if not os.path.exists(ruta_odt):
        print(f"[!] El archivo no existe en: {ruta_odt}"); return

    directorio_salida = r"C:\PROCESAMIENTO_DATOS_SEO\TABLAS_DOC"
    if not os.path.exists(directorio_salida):
        os.makedirs(directorio_salida, exist_ok=True)

    doc = load(ruta_odt)
    tablas = doc.body.getElementsByType(Table)
    
    nombre_base = os.path.splitext(os.path.basename(ruta_odt))[0]
    ruta_excel = os.path.join(directorio_salida, f"{nombre_base}_EXTRAIDO.xlsx")
    
    # Control de duplicados por clave normalizada
    claves_encontradas = {limpiar_texto_odt(item['termino']): False for item in mapa_claves}
    
    with pd.ExcelWriter(ruta_excel, engine='openpyxl') as writer:
        for t in tablas:
            filas = t.getElementsByType(TableRow)
            if not filas: continue
            celdas_xml = filas[0].getElementsByType(TableCell)
            if not celdas_xml: continue
            
            # MODIFICACIÓN (2): Comprobar contra el mapa de claves y su posición (0 o 1)
            for configuracion in mapa_claves:
                termino_original = configuracion['termino']
                posicion_busqueda = configuracion['posicion'] # 0 o 1
                termino_norm = limpiar_texto_odt(termino_original)
                
                # Seguridad: verificar que la tabla tenga la celda solicitada
                if len(celdas_xml) <= posicion_busqueda: continue
                
                valor_celda_ancla = limpiar_texto_odt(teletype.extractText(celdas_xml[posicion_busqueda]))
                
                if termino_norm in valor_celda_ancla and not claves_encontradas[termino_norm]:
                    ids, valores, tipo_tabla = extraer_datos_tabla(t)
                    
                    # Insertar tipo_tabla como primer campo (Punto 1)
                    final_ids = ["tipo_tabla"] + ids
                    final_valores = [tipo_tabla] + valores
                    
                    df = pd.DataFrame([final_valores], columns=final_ids)
                    
                    nombre_hoja = termino_original[:30]
                    df.to_excel(writer, sheet_name=nombre_hoja, index=False)
                    
                    claves_encontradas[termino_norm] = True
                    print(f"[OK] Tabla '{termino_original}' ({tipo_tabla}) guardada.")
                    break 

    print(f"\nPROCESO COMPLETADO. Ruta: {ruta_excel}")

# --- Llamada a la función principal ---

if __name__ == "__main__":

    ARCHIVO_TRABAJO = r""						#Introduce aquí la ruta absoluta del documento a procesar 
    
    MAPA_CLAVES = [								# Lista de diccionarios con término y posición (0=A1, 1=B1). Adáptala a tus necesidades
        {'termino': 'fecha', 'posicion': 0},
        {'termino': 'nie', 'posicion': 0},
        {'termino': 'escolarizado', 'posicion': 1},
        {'termino': 'admisión', 'posicion': 0},
        {'termino': 'padre', 'posicion': 0},
        {'termino': 'anteriores', 'posicion': 0},
        {'termino': 'nueva', 'posicion': 1},
        {'termino': 'discapacidad', 'posicion': 0},
        {'termino': 'ordinario', 'posicion': 1},
        {'termino': 'tipo', 'posicion': 0},
        {'termino': 'personales', 'posicion': 0}
    ]
    
    ejecutar_extraccion_a_excel(ARCHIVO_TRABAJO, MAPA_CLAVES)


Obsérvese que este script comparte la mayor parte de la estructura y la lógica con el precedente, pero presenta una diferencia de mucha importancia para el funcionamiento del script: el uso de la lista de diccionarios MAPA_CLAVES = [] que contiene la colección de términos de referencia para identificar la tabla y el código que establece la celda en la que buscar el término. La lista que aquí se presenta debe ser adaptada a las necesidades concretas que derivan del documento a procesar.

A pesar de que ahora obtenemos un .xlsx, tampoco obtenemos unas tablas que se puedan manejar directamente, siendo necesario procesarlas desde el servicio de hoja de cálculo que se desee o mediante código, aunque presentan una formulación compatible con lo que consideramos datos estructurados, lo que no impide que sea necesario organizarlos y proceder a su limpieza, si se da el caso.

Por último debo repetir aqui lo que dije al presentar el script complementario visto en su momento respecto al uso de la IA y a sus condiciones y limitaciones.

DATOS. Tratamiento de datos

Tablas complejas (.odt) (V)

Volcado masivo de datos de una tabla

Además de acceder a una celda concreta, podemos acceder también al conjunto de los datos de una tabla determinada, sea de un documento en concreto o de una collección de documentos.

Sabemos que es posible acceder a una tabla por su identificador en el navegador del procesador de texto, pero este procedimiento tiene una limitación y es que en la colección de documento, todos ellos deben tener la misma estructura de tablas, y la misma denominación, al menos para la tabla que nos interesa, lo que viene a implicar, normalmente, que todos comparten el mismo orden de las tablas. El problema es que todas estas coincidencias no son frecuentes ni fáciles de observar, lo que limita y complica el procedimiento de estracción de datos.

Para sortear estas limitaciones y ampliar el funcionalidad real del script, facilitando la obtención masiva de datos de documentos que pueden no compartir aspectos formales, pero que sí comparten contenidos (tal es el caso de los diferentes modelos de, por ejemplo, informe de evalauación psicopedagógica), una opción es identificar previamente a la extracción de datos y como condición para ella, esa tabla mediante la identificación de, por ejemplo, un concepto que de identidad a la tabla como conjunto.

Para que se entienda mejor lo que quiero decir y sus implicaciones, vamos a valorar su incidencia en un ejemplo real: la obtención de los datos de la que podría ser la Tabla1 que podría identificar el navegador.

Lo que muestra esta imagen es la tabla situada visualmente en primer lugar tanto en uno de los modelos de dictamen y otro de informe. Aunque se trata de modelos concretos, otros comparten la presencia de esta tabla en términos de estructura y de contenido, aunque se puedan observar algunas diferencias que generan (a posteriori) ciertos problemas de compatibilidad. Lo ya desde el principio no comparten es el identificador de la tabla según muestra el navegador, incluyendo que como tales no son necesariamente la Tabla1. Esto complica la viabilidad de la estrategia empleada en el script de la entrada anterior.

Cierto que podríamos utilizar determinadas estrategias para dar cabida a la variedad de identificadores de las tablas en función del documento (tipo y modelo), pero esto exige la revisión detallada de la variedad documental objeto de interés. Pero disponemos de una alternativa que evita esa revisión. La base es la siguiente: si observamos ambas tablas comprobaremos que en su celda A1 (0,0) ambas presentan un texto que es de hecho una etiqueta de la base de datos que es la tabla en su conjunto; ese texto contiene y comparte, en ambos casos, el término 'fecha'. Si, como ya sabemos, recorremos las tablas del documento, accedemos a su celda A1 y a su contenido, y realizamos una identificación en función del texto que nos interesa (en este caso 'fecha') podremos acceder al contenido de cualquiera de las celdas de esta tabla o al conjnuto de ellas. Este es el comentido del script que se muestra a continuación.



# ---0. BIBLIOTECAS NECESARIAS ---

from odf.opendocument import load
from odf.table import Table, TableRow, TableCell
from odf import teletype
import os
import csv
import sys

# --- 1. MOTOR DE LIMPIEZA ---
def limpiar_texto_odt(texto_bruto):
    if not texto_bruto: return ""
    texto = texto_bruto.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')
    return " ".join(texto.split()).lower()

# --- 2. TRADUCTOR DE COORDENADAS ---
def obtener_referencia_celda(fila_idx, col_idx):
    letras = ""
    temp_col = col_idx
    while temp_col >= 0:
        letras = chr(65 + (temp_col % 26)) + letras
        temp_col = (temp_col // 26) - 1
    return f"{letras}{fila_idx + 1}"

# --- 3. ANALIZADOR DE TABLA ---
def extraer_datos_tabla(tabla):
    filas_xml = tabla.getElementsByType(TableRow)
    if not filas_xml: return [], [], "0x0"

    max_cols = 0
    for celda in filas_xml[0].getElementsByType(TableCell):
        span = int(celda.getAttribute("numbercolumnsspanned") or 1)
        max_cols += span
    
    total_filas = len(filas_xml)
    dato_estructura = f"{total_filas}x{max_cols}"
    
    ocupada = [[False for _ in range(max_cols)] for _ in range(total_filas)]
    ids, contenidos = [], []

    for r_idx, fila in enumerate(filas_xml):
        celdas_xml = fila.getElementsByType(TableCell)
        c_xml_cursor = 0 
        for c_idx in range(max_cols):
            if ocupada[r_idx][c_idx]: continue
            if c_xml_cursor < len(celdas_xml):
                celda_actual = celdas_xml[c_xml_cursor]
                c_span = int(celda_actual.getAttribute("numbercolumnsspanned") or 1)
                r_span = int(celda_actual.getAttribute("numberrowsspanned") or 1)
                
                texto = limpiar_texto_odt(teletype.extractText(celda_actual))
                ids.append(obtener_referencia_celda(r_idx, c_idx))
                contenidos.append(texto)
                
                for i in range(r_span):
                    for j in range(c_span):
                        if r_idx + i < total_filas and c_idx + j < max_cols:
                            ocupada[r_idx + i][c_idx + j] = True
                c_xml_cursor += 1
    return ids, contenidos, dato_estructura

# --- 4. FUNCIÓN PRINCIPAL ---
def procesar_coleccion(directorio_origen, termino_busqueda, col_ancla_idx=0):
    ruta_salida_base = r"" 						# Aquí tu ruta del archivo de salida
    if not os.path.exists(ruta_salida_base):
        os.makedirs(ruta_salida_base)

    csv_maestro = os.path.join(ruta_salida_base, "") #Aquí (entre comillas) el nombre de la tabla CSV de salida
    datos_totales = []
    encabezados_globales = None
    termino = limpiar_texto_odt(termino_busqueda)

    if not os.path.exists(directorio_origen):
        print(f"[ERROR] No existe la carpeta: {directorio_origen}", flush=True)
        return

    archivos = [f for f in os.listdir(directorio_origen) if f.endswith('.odt')]
    
    print(f"\n>>> INICIO: Detectados {len(archivos)} archivos.", flush=True)	 # flush=True fuerza a mostrar el texto

    for nombre_archivo in archivos:
        ruta_completa = os.path.join(directorio_origen, nombre_archivo)
        try:
            doc = load(ruta_completa)
            tablas = doc.body.getElementsByType(Table)
            encontrado = False
            
            for t in tablas:
                filas = t.getElementsByType(TableRow)
                if not filas: continue
                celdas = filas[0].getElementsByType(TableCell)
                
                if len(celdas) <= col_ancla_idx: continue
                
                valor_ancla = limpiar_texto_odt(teletype.extractText(celdas[col_ancla_idx]))
                
                if termino in valor_ancla:
                    ids, valores, estructura = extraer_datos_tabla(t)
                    if encabezados_globales is None:
                        encabezados_globales = ["ARCHIVO_ORIGEN", "ESTRUCTURA"] + ids
                    
                    datos_totales.append([nombre_archivo, estructura] + valores)
                    print(f" [OK] {nombre_archivo} | Estructura: {estructura}", flush=True)
                    encontrado = True
                    break
        except Exception as e:
            print(f" [ERR] Error en {nombre_archivo}: {e}", flush=True)

    if datos_totales:
        with open(csv_maestro, mode='a', newline='', encoding='utf-8-sig') as f:
            escritor = csv.writer(f)
            escritor.writerow(encabezados_globales)
            escritor.writerows(datos_totales)
        print(f"\n>>> ÉXITO: CSV generado en {csv_maestro}", flush=True)
    else:
        print("\n>>> AVISO: No se encontraron coincidencias.", flush=True)

# --- Llamada a la función ---

if __name__ == "__main__":
    CARPETA = r""		# Aquí el directorio de los archivos a trabajar
    TERMINO = ""		# Aquí el término de identificación
    # 0 = A1, 1 = B1	# Identificadores de celda de referencia
    procesar_coleccion(CARPETA, TERMINO, 0)


Este script es suficientemente complejo como para necesitar una explicación con cierto detalle. Tenemos un script que desarrolla un sistema automatizado de extracción de datos jerarquizado que utiliza la biblioteca odfpy. Consta o requiere unas funciones que identificamos como secundarias, una función principal y el código de llamada a la función.

Las funciones secundarias realizan tareas de limpieza def limpiar_texto_odt(texto_bruto):, cálculo de coordenadas para la identificación de las celdas def obtener_referencia_celda(fila_idx, col_idx): y para la reconstrucción de celdas combinadas def extraer_datos_tabla(tabla):.

def procesar_coleccion(directorio_origen, termino_busqueda, col_ancla_idx=0): es la función principal, la que gestiona el flujo de archivos, la búsqueda del término identificador y el almacenamiento de los datos como CVS. Esta función es la que llama a las funciones secundarias y es llamada, a su vez, por el script principal.

Este script contiene la identificación de variables (ruta de archivos, término de búsqueda y celda de referencia para la búsqueda) y la llamada a la función principal. Sobre la doble opción de celda, decir que se ha implementado para evitar la dificultad que presentan aquellas tablas cuya celda A1 puede resultar inapropiada por diversos motivos, por ejemplo, por estar vacía sin que ello implique algún tipo de irregularidad.

Sin la ayuda de IA-Gemini no habría sido posible desarrollarla, pero incluso con ella me resultó de gran dificultad, dado que fue necesario reformular el procedimiento varias veces, reconstruirlo paso a paso y controlar que el funcionamiento de cada una de las partes y del conjunto se ajustar a lo esperado y se alcanzara objetivo final: obtener un archivo CSV que contenga el texto de cada una de las celdas de la tabla de dos conjuntos de documentos que comparten (como se aprecia en la imagen superior) la estructura y el contenido de esa tabla identificada por el string 'fecha'.

No se prevé, no obstante, que ese documento resultante sea de utilidad inmediata, ya que se precisa cierto tratamiento que puede ejecutarse, eso sí, manualmente sobre el servico de hoja de cálculo o mediante código. Pero este es un proceso que deriva a una segunda fase del tratamiento de datos: el de la organización y limpieza de datos obtenidos mediante procedimientos de extracción.