Files
IPSExtractor/application/preclassification.py
T

471 lines
20 KiB
Python

import json
import logging
import os
from typing import List, Dict, Any, Optional, Tuple, Callable
import re
import sqlite3
from application.strategies import OllamaExtractor
from application.database import DatabaseManager
from core.pdf_utils import document_to_images_bs64
from core.utils import collect_radicado_docs
logger = logging.getLogger(__name__)
import shutil
import pandas as pd
class PreclassificationService:
def __init__(self, extractor: OllamaExtractor):
self.extractor = extractor
self.db = DatabaseManager()
def export_to_excel(self, run_id: int, output_path: str) -> bool:
"""Exporta los resultados de una ejecución a Excel (xlsx) o CSV."""
try:
results = self.db.get_run_results(run_id)
if not results:
return False
# Pivotar para tener filas únicas por archivo
# get_run_results devuelve una fila por cada key encontrada en cada archivo
# Necesitamos agrupar por (radicado, file_path)
data_map = {}
keys_seen = set()
for row in results:
file_id = f"{row['radicado']}|{row['file_path']}"
if file_id not in data_map:
data_map[file_id] = {
"Radicado": row["radicado"],
"Archivo": row["file_path"],
"Status": row["status"]
}
k = row["key"]
if k:
# found_value puede ser None
val = row["found_value"]
exists = row["exists_bool"]
# Podemos poner el valor o un indicador
data_map[file_id][k] = val if exists else "NO_FOUND"
keys_seen.add(k)
# Convertir a lista
final_data = list(data_map.values())
df = pd.DataFrame(final_data)
# Asegurar todas las columnas de keys
for k in keys_seen:
if k not in df.columns:
df[k] = None
if output_path.endswith(".csv"):
df.to_csv(output_path, index=False, encoding="utf-8-sig", sep=";")
else:
df.to_excel(output_path, index=False)
return True
except Exception as e:
logger.exception("Error exportando a Excel")
raise e
def organize_files(self, run_id: int, base_output_path: str) -> tuple[int, int]:
"""
Organiza los archivos MATCH en carpetas jerárquicas.
Devuelve (total_copiados, total_errores).
"""
# Obtener modo del Run
# Necesitamos leer config del Run
conn = sqlite3.connect(self.db.db_path)
c = conn.cursor()
c.execute("SELECT config FROM runs WHERE id = ?", (run_id,))
row = c.fetchone()
conn.close()
mode = "radicados"
if row and row[0]:
try:
cfg = json.loads(row[0])
mode = str(cfg.get("mode", "radicados") or "radicados").strip().lower()
except Exception:
mode = "radicados"
if mode not in ("radicados", "general"):
mode = "radicados"
results = self.db.get_run_results(run_id)
# Filtrar solo MATCH
match_rows = [r for r in results if r["status"] == "MATCH"]
if not match_rows:
return 0, 0
# Agrupar por archivo para tener el "set" de datos de cada uno
files_data = {} # { file_path: {key: val, ...}, 'radicado': ... }
key_values_all = {} # { key: set(values) }
for row in match_rows:
fpath = row["file_path"]
if not fpath: continue
if fpath not in files_data:
files_data[fpath] = {"radicado": row["radicado"], "keys": {}}
k = row["key"]
v = row["found_value"]
if k and v:
files_data[fpath]["keys"][k] = v
if k not in key_values_all:
key_values_all[k] = set()
key_values_all[k].add(v)
# Determinar orden de jerarquía por cardinalidad
# Lista de (key, cardinalidad)
hierarchy = []
for k, val_set in key_values_all.items():
hierarchy.append((k, len(val_set)))
# Ordenar ascendente: primero los que tienen menos valores (ej. Categorias generales)
hierarchy.sort(key=lambda x: x[1])
ordered_keys = [x[0] for x in hierarchy]
logger.info(f"Jerarquía de carpetas calculada: {ordered_keys}")
count_ok = 0
count_err = 0
for fpath, info in files_data.items():
try:
# Construir ruta relativa
# Sanitizar valores para que sean nombres de carpeta válidos
path_parts = []
for k in ordered_keys:
val = info["keys"].get(k, "DESCONOCIDO")
safe_val = self._sanitize_filename(str(val))
# KeyName / ValueName
safe_key = self._sanitize_filename(k)
path_parts.append(safe_key)
path_parts.append(safe_val)
# Añadir radicado al final solo si NO es modo general (en general el radicado es el archivo)
# Si mode es radicados: ... / Radicado / Archivo
# Si mode es general: ... / Archivo
if mode != "general":
path_parts.append(self._sanitize_filename(info["radicado"]))
dest_dir = os.path.join(base_output_path, *path_parts)
os.makedirs(dest_dir, exist_ok=True)
fname = os.path.basename(fpath)
dest_file = os.path.join(dest_dir, fname)
# Evitar sobrescrituras (especialmente en modo general sin radicado)
if os.path.exists(dest_file):
base, ext = os.path.splitext(fname)
n = 1
while True:
candidate = os.path.join(dest_dir, f"{base}_{n}{ext}")
if not os.path.exists(candidate):
dest_file = candidate
break
n += 1
# Copiar
shutil.copy2(fpath, dest_file)
count_ok += 1
except Exception as e:
logger.error(f"Error organizando {fpath}: {e}")
count_err += 1
return count_ok, count_err
def _sanitize_filename(self, name: str) -> str:
"""Limpia caracteres inválidos para rutas de archivo."""
# Reemplazar caracteres no alfanuméricos (excepto espacio, guion, punto)
clean = re.sub(r'[\\/*?:"<>|]', "", name)
clean = clean.replace("\n", " ").replace("\r", "").strip()
if not clean:
return "UNTITLED"
return clean[:50] # Limitar longitud
def run_preclassification(
self,
root_path: str,
patterns: List[Dict[str, str]], # [{key, mode, value?}, ...]
target_info: Dict[str, Any], # {enabled, definition, pattern}
page_range: Tuple[bool, int, int], # (enabled, start, end)
on_progress: Optional[Callable[[str, int, int], None]] = None,
mode: str = "radicados" # "radicados" | "general"
) -> int:
"""
Ejecuta el flujo de preclasificación.
Devuelve el run_id.
"""
# 1. Crear Run en BD
mode = str(mode or "radicados").strip().lower()
if mode not in ("radicados", "general"):
mode = "radicados"
run_config = {
"patterns": patterns,
"target": target_info,
"range": page_range,
"mode": mode
}
run_id = self.db.create_run(root_path, run_config)
# 2. Descubrir items a procesar
radicados = []
if os.path.isdir(root_path):
if mode == "general":
# En modo general, consideramos que la raiz es el único "contenedor"
# o iteramos sobre una lista dummy.
# Para simplificar el loop principal, usaremos una lista con un elemento "."
radicados = ["."]
else:
radicados = sorted([
d for d in os.listdir(root_path)
if os.path.isdir(os.path.join(root_path, d))
])
total = len(radicados)
if mode == "general":
# Si es general, quizás queramos reportar progreso por archivo, no por carpeta "."
# Pero _identify_files nos da los archivos.
pass
# Preparar prompt
keys_to_extract = [p["key"] for p in patterns]
prompt = self._build_prompt(keys_to_extract)
for idx, rad in enumerate(radicados, start=1):
# Si es general, rad es ".", no es util para el usuario ver "Procesando ."
# Ajustaremos el reporte dentro del loop de archivos si es general
if mode != "general" and on_progress:
on_progress(rad, idx, total)
rad_path = os.path.join(root_path, rad)
# 3. Identificar archivos a procesar
files_to_process = self._identify_files(rad_path, target_info, mode)
logger.info(f"Radicado/Carpeta: {rad} - Archivos identificados: {len(files_to_process)}")
# Ordenar archivos para consistencia
files_to_process.sort()
if not files_to_process:
if mode == "general":
# Registrar una sola vez que no había archivos
self.db.add_result(run_id, "GENERAL", None, "NO_FILES", "")
return run_id
else:
self.db.add_result(run_id, rad, None, "NO_FILES", "")
continue
total_files = len(files_to_process)
for f_idx, file_path in enumerate(files_to_process, 1):
# En modo general, el "radicado" lógico será el nombre del archivo sin extensión
# En modo radicados, es el nombre de la carpeta (variable rad)
current_radicado = rad
# Definir radicado para modo general ANTES de cualquier lógica
if mode == "general":
base_name = os.path.basename(file_path)
name_no_ext = os.path.splitext(base_name)[0]
# Validar que no quede vacio ni sea un punto
if name_no_ext and name_no_ext.strip() not in (".", ""):
current_radicado = name_no_ext.strip()
else:
current_radicado = base_name
# --- CORRECCIÓN FORZADA: Si sigue siendo un punto o vacío, usar timestamp o hash ---
if str(current_radicado).strip() in (".", ""):
import time
current_radicado = f"FILE_{int(time.time()*1000)}_{f_idx}"
logger.info(f"Procesando archivo [{f_idx}/{total_files}]: {file_path} -> Radicado: {current_radicado}")
if on_progress:
try:
# Asegurar que pasamos un string válido
safe_rad = str(current_radicado)
on_progress(safe_rad, f_idx, total_files)
except Exception:
pass # Evitar romper el loop si la GUI falla
try:
# 4. Convertir a imágenes
# Manejo de rangos
tiff_range = None
if page_range[0]: # enabled
# document_to_images_bs64 espera (start, end)
# Nota: pdf_pages_to_images_bs64 usa start, end.
# document_to_images_bs64 usa tiff_page_range=(start, end).
# Para PDF hay que ver como lo manejamos si usamos document_to_images_bs64
# La función document_to_images_bs64 no expone rango para PDF directamente,
# pero pdf_to_images_bs64 sí via pdf_pages_to_images_bs64?
# No, pdf_to_images_bs64 convierte todo.
# Vamos a usar document_to_images_bs64 y si es PDF y hay rango, lo cortamos post-procesamiento o usamos función específica.
# Mejor: document_to_images_bs64 llama a pdf_to_images_bs64 que NO filtra.
# Debemos implementar logica de rango aqui.
pass
images_b64 = self._get_images(file_path, page_range)
if not images_b64:
self.db.add_result(run_id, rad, file_path, "ERROR_IMG", "No images generated")
continue
# 5. LLM
response = self.extractor.extract(images_b64, prompt)
raw_text = response.get("raw", "")
# 6. Parseo y Validación
parsed = self._parse_json_response(raw_text)
# Evaluar reglas
is_match = True
# Si no hay patrones definidos, quizás asumimos match si responde JSON válido?
# Pero el usuario pidió validar patrones.
# Guardar resultado cabecera
result_id = self.db.add_result(run_id, current_radicado, file_path, "PROCESSED", raw_text)
# Guardar detalles y calcular match global
global_match = True
if not parsed:
global_match = False
# Guardar error de parseo?
else:
for pattern in patterns:
key = pattern["key"]
pat_mode = pattern.get("mode", "key")
expected_val = pattern.get("value", "").lower().strip()
# Buscar en respuesta
# Normalizamos claves para busqueda insensible a mayusculas
found_item = None
for k, v in parsed.items():
if k.lower() == key.lower():
found_item = v
break
exists = False
found_val = None
if found_item and isinstance(found_item, dict):
exists = bool(found_item.get("exists", False))
found_val = str(found_item.get("value", "") or "")
# Regla
rule_ok = False
if pat_mode == "key":
rule_ok = exists
elif pat_mode == "key+value":
# Comparacion laxa
rule_ok = exists and (expected_val in found_val.lower())
if not rule_ok:
global_match = False
self.db.add_match(result_id, key, expected_val if pat_mode=="key+value" else None, found_val, exists)
# Actualizar status final
final_status = "MATCH" if global_match else "NO_MATCH"
self._update_result_status(result_id, final_status)
except Exception as e:
logger.exception(f"Error procesando {file_path}")
self.db.add_result(run_id, current_radicado, file_path, f"ERROR: {str(e)}", "")
return run_id
def _identify_files(self, rad_path: str, target_info: Dict[str, Any], mode: str = "radicados") -> List[str]:
"""Devuelve lista de rutas de archivos a procesar."""
if not os.path.exists(rad_path):
return []
# Si mode es general, rad_path es la raiz y buscamos archivos directos.
# Si mode es radicados, rad_path es la subcarpeta del radicado.
# La logica es la misma: listar archivos en el directorio dado.
all_files = []
try:
for f in os.listdir(rad_path):
full_path = os.path.join(rad_path, f)
if os.path.isfile(full_path) and f.lower().endswith(('.pdf', '.tif', '.tiff')):
all_files.append(full_path)
except Exception:
pass
if not target_info.get("enabled"):
return all_files
# Filtrar por patron
pattern = target_info.get("pattern", "*").lower()
# Convertir glob simple a regex o usar fnmatch
import fnmatch
filtered = [f for f in all_files if fnmatch.fnmatch(os.path.basename(f).lower(), pattern)]
return filtered
def _get_images(self, file_path: str, page_range: Tuple[bool, int, int]) -> List[str]:
"""Convierte archivo a imagenes, aplicando rango si es necesario."""
enabled, start, end = page_range
# Si rango habilitado, usar lógica especifica
if enabled:
ext = os.path.splitext(file_path)[1].lower()
if ext == ".pdf":
from core.pdf_utils import pdf_pages_to_images_bs64
# pdf_pages_to_images_bs64 espera start, end (inclusive)
# Validar indices positivos
s = max(1, start)
e = max(1, end)
if s > e: s, e = e, s
return pdf_pages_to_images_bs64(file_path, s, e, dpi=300) # DPI medio para velocidad?
elif ext in (".tif", ".tiff"):
# document_to_images_bs64 soporta tiff_page_range
s = max(1, start)
e = max(1, end)
if s > e: s, e = e, s
return document_to_images_bs64(file_path, tiff_page_range=(s, e), dpi=300)
# Sin rango
return document_to_images_bs64(file_path, dpi=300)
def _build_prompt(self, keys: List[str]) -> str:
keys_str = ", ".join(keys)
return f"""Responde con true o false si tiene los siguientes campos, si existe extrae cuales son.
Responde ÚNICAMENTE en JSON con el siguiente formato, sin bloques de código markdown:
{{
"nombre_campo": {{
"exists": true,
"value": "valor extraído"
}},
...
}}
Campos a buscar: {keys_str}"""
def _parse_json_response(self, text: str) -> Dict[str, Any]:
try:
# Limpiar bloques markdown si el modelo los pone
clean = text.replace("```json", "").replace("```", "").strip()
return json.loads(clean)
except Exception:
# Intento de rescate simple con regex si falla json puro
# Por ahora retornamos vacio para indicar fallo
return {}
def _update_result_status(self, result_id: int, status: str):
# Actualización hacky directa porque DatabaseManager no tiene update expuesto
# Podría agregar update_result en DatabaseManager, pero por brevedad:
import sqlite3
conn = sqlite3.connect(self.db.db_path)
c = conn.cursor()
c.execute("UPDATE results SET status = ? WHERE id = ?", (status, result_id))
conn.commit()
conn.close()