commit 69debb1c9337859ce5b1ed8d6e7cadc7291f6e1c Author: erik Date: Mon Feb 23 09:24:06 2026 -0500 feat: Implement the initial IPSExtractor project structure, including application logic, GUI, core utilities, and documentation. diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..ba2d2e3 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,3 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +.venv/ +dependencies/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6221c05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,211 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,jupyternotebooks +# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,jupyternotebooks + +### JupyterNotebooks ### +# gitignore template for Jupyter Notebooks +# website: http://jupyter.org/ + +.ipynb_checkpoints +*/.ipynb_checkpoints/* + +# IPython +profile_default/ +ipython_config.py + +# Remove previous ipynb_checkpoints +# git rm -r .ipynb_checkpoints/ + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,jupyternotebooks + +*.pdf +*.csv +*.json +dependencies/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ec29b7 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +## IPSExtractor + +Sistema avanzado para la extracción y procesamiento de datos de facturas médicas (IPS) utilizando modelos de Inteligencia Artificial Multimodal (LLMs). Convierte documentos (PDF/TIFF) a imágenes, extrae información estructurada, la normaliza y la exporta a CSV. + +### 🚀 Características Principales + +- **Extracción Multimodal**: Usa modelos de visión (como `qwen2.5vl`) vía API compatible con OpenAI/Ollama para "leer" documentos como imágenes. +- **Procesamiento Inteligente**: + - Conversión automática de PDF/TIFF a imágenes optimizadas. + - Descubrimiento automático de documentos por radicado (prioriza TIFF). + - **Normalización de Datos**: Estandarización de salidas JSON mediante post-procesamiento con LLM. +- **Gestión Robusta**: + - Sistema de **Logs Estructurados** (JSON) y salida en consola coloreada para fácil depuración. + - Estimación de costos y tokens antes de ejecutar. + - Manejo de reintentos y "fallback" automático (reducción de DPI) en caso de fallos. +- **Interfaz Gráfica (GUI)**: + - Selección interactiva de carpetas y configuración. + - Selector visual de campos a extraer (Schema Builder). + - Barra de progreso y logs en tiempo real. +- **Exportación**: Generación incremental de reportes CSV (una fila por ítem de factura). + +### 📋 Requisitos + +- **Python 3.10+** +- **Poppler** (para procesar PDFs): + - Windows: Incluido en `dependencies/poppler-24.08.0/Library/bin`. + - Linux/Mac: Instalar vía gestor de paquetes (`sudo apt install poppler-utils`). +- **Acceso a LLM**: Un servidor Ollama corriendo localmente o acceso a una API compatible con OpenAI. + +### 🛠️ Instalación + +1. **Clonar el repositorio y crear entorno virtual**: + ```bash + python -m venv .venv + # Windows + .venv\Scripts\activate + # Linux/Mac + source .venv/bin/activate + ``` + +2. **Instalar dependencias**: + ```bash + pip install -r requirements.txt + ``` + +### ⚙️ Configuración (.env) + +La configuración se gestiona mediante variables de entorno. Crea un archivo `.env` en la raíz con los siguientes parámetros (ajusta según tu entorno): + +```env +# Configuración del Modelo Extractor +EXTRACTOR_BASE_URL=http://10.8.0.4:11434/v1 +EXTRACTOR_API_KEY=ollama +EXTRACTOR_MODEL=qwen2.5vl:32b +EXTRACTOR_TIMEOUT_S=120 + +# Configuración de Procesamiento de Imagen +PDF_DPI=600 +PDF_THREAD_COUNT=10 +TIFF_PAGES=(2,3) + +# Configuración de Rutas y Salida +POPPLER_PATH=dependencies/poppler-24.08.0/Library/bin +CSV_DELIMITER=| +``` + +### 🖥️ Uso de la Aplicación + +Ejecuta el punto de entrada principal: + +```bash +python gui_main.py +``` + +#### Flujo de Trabajo en GUI: +1. **Pestaña "Procesar"**: + * Selecciona la carpeta raíz que contiene las subcarpetas de los radicados. + * (Opcional) Define keywords para filtrar archivos específicos. + * Selecciona dónde guardar el CSV de salida. + * Haz clic en **"Procesar"**. + * *Si es la primera vez*: Se abrirá un selector de campos para que elijas qué datos extraer basándose en el primer documento detectado. +2. **Pestaña "Configuración"**: + * Permite ajustar dinámicamente la URL de la API o el modelo sin reiniciar la aplicación. + +### 🏗️ Arquitectura del Código + +El proyecto sigue una arquitectura modular para facilitar el mantenimiento y la escalabilidad: + +**Capa de Aplicación (`application/`):** +- `service.py`: **Fachada** principal (`InvoiceProcessingService`). Punto de entrada único para la lógica de negocio. +- `batch_processor.py`: Motor de procesamiento masivo. Maneja la iteración de radicados, reportes de progreso y orquestación multi-hilo. +- `estimator.py`: Lógica para calcular costos estimados y uso de tokens antes de lanzar procesos largos. +- `strategies.py`: Implementación del cliente LLM (`OllamaExtractor`) y patrones de extracción. +- `normalizer.py`: Servicio de normalización de datos JSON post-extracción para asegurar consistencia en el esquema. +- `csv_manager.py`: Manejador thread-safe para la escritura incremental de archivos CSV. +- `logging_config.py`: Configuración avanzada de logging (salida JSON para máquinas y colores para humanos). +- `file_discovery.py`: Utilidades para exploración eficiente del sistema de archivos. + +**Capa Core (`core/`):** +- `pdf_utils.py`: Wrapper sobre Poppler para la rasterización de documentos PDF/TIFF. +- `text_utils.py`: Herramientas de limpieza de texto y extracción robusta de bloques JSON. +- `csv_utils.py`: Utilidades puras para aplanamiento de diccionarios anidados y generación de cabeceras. + +**Capa GUI (`gui/`):** +- Interfaz construida con `customtkinter`, implementando un patrón MVC básico (View, Controller). + +### 📊 Logs y Depuración + +El sistema genera logs detallados en la carpeta `logs/` para auditoría y depuración: +- `ips_extractor.log`: Log principal de la aplicación (formato JSON estructurado). +- `llm.log`: Trazabilidad específica de las interacciones con el LLM (prompts y respuestas). +- `api_calls.log`: Métricas de uso de API y tokens. + +Además, la salida por consola está optimizada con colores para una lectura rápida del estado del procesamiento. + +### 📝 Licencia +Uso interno. Ajuste según sus necesidades. diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 0000000..46b84e0 --- /dev/null +++ b/application/__init__.py @@ -0,0 +1,14 @@ +"""Paquete `application`: lógica de alto nivel de extracción y orquestación. + +Incluye: +- `service`: fachada de procesamiento de facturas. +- `flows`: flujos y utilidades para encabezados/CSV y ejecución paralela. +- `normalizer`: normalización LLM a un esquema canónico. +- `strategies`: estrategias de extracción (cliente OpenAI/Ollama). +- `profiles`: perfiles por tipo de documento e instrucciones por defecto. +- `prompts`: construcción de prompts de extracción/normalización. +- `config`: configuración centralizada (Pydantic BaseSettings). +- `logging_config`: configuración de logging JSON y loggers dedicados. +""" +from __future__ import annotations + diff --git a/application/batch_processor.py b/application/batch_processor.py new file mode 100644 index 0000000..77ed714 --- /dev/null +++ b/application/batch_processor.py @@ -0,0 +1,588 @@ +"""Procesador por lotes para la extracción de facturas. + +Encapsula la lógica de iteración sobre radicados, extracción, normalización y exportación a CSV. +""" +import os +import logging +import asyncio +import openai +from typing import List, Dict, Any, Optional, Tuple, Callable, Awaitable + +from application.profiles import get_profile +from application.prompts import PromptBuilder +from application.normalizer import normalize_to_schema +from application.costs import compute_cost_from_usage +from application.file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados +from application.csv_manager import CSVManager +from application.config import settings +from core.utils import collect_radicado_docs, find_first_matching_file, collect_documents_flat +from core.csv_utils import rows_from_data + +logger = logging.getLogger(__name__) + +class BatchProcessor: + def __init__(self, service): + """ + Args: + service: Instancia de InvoiceProcessingService para delegar la extracción individual. + """ + self.service = service + self.csv_manager = CSVManager() + self._pause_event = asyncio.Event() + self._pause_event.set() # No pausado inicialmente + self._stop_requested = False + + def process_root( + self, + root_path: str, + keywords: List[str] | None = None, + *, + doc_types: Optional[List[Dict[str, str]]] = None, + fields: Optional[List[str]] = None, + on_progress: Optional[callable] = None, + csv_output_path: Optional[str] = None, + csv_header_override: Optional[List[str]] = None, + instruction_override: Optional[str] = None, + on_usage: Optional[Callable[[str, str, Dict[str, Any] | None, Dict[str, Any] | None], None]] = None, + on_error_retry: Optional[Callable[[Exception], Awaitable[bool]]] = None, + alias_map: Optional[Dict[str, str]] = None, + tiff_page_range: Optional[Tuple[int, int]] = None, + structure_mode: str = "radicados", + ) -> List[Dict[str, Any]]: + """Procesa radicados con selección de campos y CSV incremental. + + Delega en métodos específicos para multi-documento o single-document. + """ + # Resetear estado de pausa/parada + self._pause_event.set() + self._stop_requested = False + + # 1. Preparación de Schemas (Fase 1 Multi-doc) + if doc_types and not fields: + return self._prepare_multidoc_schemas(root_path, doc_types, instruction_override, tiff_page_range, structure_mode) + + # 2. Procesamiento Multi-doc (Fase 2) - ASYNC + if doc_types and fields: + return asyncio.run(self._process_multidoc_async( + root_path=root_path, + doc_types=doc_types, + fields=fields, + csv_output_path=csv_output_path, + csv_header_override=csv_header_override, + alias_map=alias_map, + tiff_page_range=tiff_page_range, + on_progress=on_progress, + on_usage=on_usage, + on_error_retry=on_error_retry, + structure_mode=structure_mode + )) + + # 3. Procesamiento Single-doc (Retrocompatibilidad) + # Si no hay fields, es la fase de detección de esquema (secuencial, solo 1 doc) + if not fields: + return self._process_singledoc( + root_path=root_path, + keywords=keywords, + fields=fields, + csv_output_path=csv_output_path, + csv_header_override=csv_header_override, + instruction_override=instruction_override, + alias_map=alias_map, + tiff_page_range=tiff_page_range, + on_progress=on_progress, + on_usage=on_usage, + structure_mode=structure_mode + ) + else: + # Si hay fields, es procesamiento masivo -> ASYNC + return asyncio.run(self._process_singledoc_async( + root_path=root_path, + keywords=keywords, + fields=fields, + csv_output_path=csv_output_path, + csv_header_override=csv_header_override, + instruction_override=instruction_override, + alias_map=alias_map, + tiff_page_range=tiff_page_range, + on_progress=on_progress, + on_usage=on_usage, + on_error_retry=on_error_retry, + structure_mode=structure_mode + )) + + def _prepare_multidoc_schemas(self, root_path, doc_types, instruction, tiff_page_range, structure_mode): + schema_by_type, first_full_by_type, first_radicado = self.service.build_schema_from_first_for_types( + root_path, + doc_types, + instruction=instruction, + tiff_page_range=tiff_page_range, + structure_mode=structure_mode + ) + return [{ + "_needs_fields": True, + "schema_by_type": schema_by_type, + "first_full_by_type": first_full_by_type, + "first_radicado": first_radicado, + }] + + async def _process_multidoc_async( + self, root_path, doc_types, fields, csv_output_path, + csv_header_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None, + structure_mode="radicados" + ): + csv_header = self._prepare_csv_header(fields, alias_map, csv_header_override) + + if structure_mode == "general": + radicados = get_files_as_radicados(root_path) + else: + radicados = get_sorted_radicados(root_path) + + if not radicados and not os.path.isdir(root_path): + raise ValueError(f"La ruta raíz no es un directorio: {root_path}") + + total = len(radicados) + prof = get_profile('invoice') + + # Limitar concurrencia (Configurable) + limit = settings.processing.concurrency_limit + if limit < 1: limit = 1 + sem = asyncio.Semaphore(limit) + + # Para mantener el orden de escritura en CSV, usamos un Lock + csv_lock = asyncio.Lock() + + # Contador thread-safe para progreso + completed_count = 0 + progress_lock = asyncio.Lock() + + async def process_radicado(idx, rad): + nonlocal completed_count + async with sem: + while True: # Bucle para permitir reintentos tras pausa + await self._pause_event.wait() + if self._stop_requested: + return {"radicado": rad, "status": "stopped"} + + try: + # Determinar ID visual y ruta + if structure_mode == "general": + rad_id = os.path.splitext(rad)[0] + rad_path = os.path.join(root_path, rad) # Es el archivo + else: + rad_id = rad + rad_path = os.path.join(root_path, rad) # Es la carpeta + + logger.info(f"Procesando radicado {rad_id} (multi) [{idx}/{total}]", extra={"radicado": rad_id}) + + fused_data = {"radicado": rad_id} + agg_extract_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}} + agg_normalize_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}} + + for dt in doc_types: + tname = str(dt.get("name") or "").strip() or "Tipo" + kw = str(dt.get("keyword") or "").strip() + + match = None + if structure_mode == "general": + # En modo general, el archivo ES el documento. + # Opcional: filtrar si el nombre coincide con keyword, pero usualmente + # si el usuario elige "General" y "Tipos", quiere aplicar los tipos a ese archivo. + # Pero si tiene varios tipos (Factura, Anexo), no tiene sentido aplicarlos todos al mismo archivo + # a menos que quiera intentar extraer cosas distintas. + # Asumiremos que el archivo aplica para el tipo. + match = rad_path + else: + try: + files = sorted(await asyncio.to_thread(os.listdir, rad_path)) + except Exception: + files = [] + + match_name = find_first_matching_file(files, kw) if kw else None + match = os.path.join(rad_path, match_name) if match_name else None + + if not match: + logger.info(f"[{rad_id}] Tipo '{tname}' sin archivo coincidente (kw='{kw}')") + fused_data[tname] = {} + continue + + # Extraer + selected_paths_for_type = [p.split('/', 1)[1] for p in (fields or []) if p.startswith(f"{tname}/")] + try: + prompt_t = PromptBuilder.build_for_type(tname, selected_paths_for_type, {}, profile_name=prof.get('name', 'invoice')) + except Exception: + prompt_t = PromptBuilder.build(selected_paths_for_type, {}, profile_name=prof.get('name', 'invoice')) + + # Llamada ASYNC + data, usage_ext = await self.service.extract_full_from_pdf_async(match, instruction=prompt_t, tiff_page_range=tiff_page_range) + self._accumulate_usage(agg_extract_usage, usage_ext) + + # Normalizar (CPU bound, mover a thread) + if isinstance(data, dict): + data['radicado'] = rad_id + else: + data = {"radicado": rad_id, "data": data} + + # Normalización en thread aparte + norm = await asyncio.to_thread( + normalize_to_schema, + data, + paths=selected_paths_for_type, + profile_name=prof.get('name', 'invoice') + ) + + normalized_obj = data + n_usage = None + if isinstance(norm, dict): + n_usage = norm.get("_usage") + normalized_obj = norm.get("normalized") or data + + self._accumulate_usage(agg_normalize_usage, n_usage) + fused_data[tname] = normalized_obj or {} + fused_data["_source_pdf"] = match + + # Report usage + self._report_usage(on_usage, rad_id, "extract", agg_extract_usage) + self._report_usage(on_usage, rad_id, "normalize", agg_normalize_usage) + + # CSV Export protegido por lock + if csv_output_path: + async with csv_lock: + await asyncio.to_thread( + self._write_csv_multidoc, + csv_output_path, fused_data, fields, doc_types, + prof, csv_header, alias_map, rad_id + ) + + return fused_data + + except openai.RateLimitError as e: + if on_error_retry: + # Pausar todos los demás + self._pause_event.clear() + try: + should_retry = await on_error_retry(e) + if should_retry: + self._pause_event.set() + continue # Reintentar este radicado + else: + self._stop_requested = True + self._pause_event.set() + return self._handle_error_async(rad, e, csv_output_path, csv_header) + except Exception as e_retry: + self._pause_event.set() + return self._handle_error_async(rad, e_retry, csv_output_path, csv_header) + else: + return self._handle_error_async(rad, e, csv_output_path, csv_header) + except Exception as e: + return self._handle_error_async(rad, e, csv_output_path, csv_header) + finally: + async with progress_lock: + completed_count += 1 + self._notify_progress(on_progress, rad, completed_count, total) + break # Salir del bucle True si no hubo RateLimitError o se manejó + + tasks = [process_radicado(i, r) for i, r in enumerate(radicados, start=1)] + resultados = await asyncio.gather(*tasks) + return list(resultados) + + def _process_singledoc( + self, root_path, keywords, fields, csv_output_path, + csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None, + structure_mode="radicados" + ): + if structure_mode == "general": + entries = collect_documents_flat(root_path, keywords) + else: + entries = collect_radicado_docs(root_path, keywords or []) + + if not entries: + logger.info("No se encontraron radicados con PDFs que coincidan.") + return [] + + # Primer documento para schema + first = entries[0] + prof = get_profile('invoice') + inst0 = instruction_override if instruction_override is not None else prof["default_instruction"] + schema, first_full = self.service.build_schema_from_first(first["path"], instruction=inst0, tiff_page_range=tiff_page_range) + + selected_fields = fields or [] + if not selected_fields: + return [{ + "_needs_fields": True, + "schema": schema, + "first_full": first_full, + "radicado": first["radicado"], + "_source_pdf": first["path"], + }] + + # Fallback a procesamiento secuencial/async si se llama directamente + return asyncio.run(self._process_singledoc_async( + root_path, keywords, fields, csv_output_path, csv_header_override, + instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry, structure_mode + )) + + async def _process_singledoc_async( + self, root_path, keywords, fields, csv_output_path, + csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None, + structure_mode="radicados" + ): + # Recolección de archivos (IO bloqueante) + if structure_mode == "general": + entries = await asyncio.to_thread(collect_documents_flat, root_path, keywords) + else: + entries = await asyncio.to_thread(collect_radicado_docs, root_path, keywords or []) + + if not entries: + logger.info("No se encontraron radicados con PDFs que coincidan.") + return [] + + selected_fields = fields or [] + prompt = PromptBuilder.build(selected_fields, {}) + csv_header = self._prepare_csv_header(selected_fields, alias_map, csv_header_override) + + prof = get_profile('invoice') + total = len(entries) + + # Limitar concurrencia (Configurable) + limit = settings.processing.concurrency_limit + if limit < 1: limit = 1 + sem = asyncio.Semaphore(limit) + + csv_lock = asyncio.Lock() + progress_lock = asyncio.Lock() + completed_count = 0 + + async def process_entry(idx, entry): + nonlocal completed_count + async with sem: + while True: # Bucle para permitir reintentos tras pausa + await self._pause_event.wait() + if self._stop_requested: + return {"radicado": entry['radicado'], "status": "stopped"} + + try: + logger.info(f"Procesando radicado {entry['radicado']} [{idx}/{total}]", extra={"radicado": entry['radicado'], "pdf": entry['path']}) + + # Async Extract + data, usage_ext = await self.service.extract_full_from_pdf_async(entry["path"], instruction=prompt, tiff_page_range=tiff_page_range) + self._report_usage(on_usage, entry['radicado'], "extract", usage_ext) + + if isinstance(data, dict): + data['radicado'] = entry['radicado'] + else: + data = {"radicado": entry['radicado'], "data": data} + data["_source_pdf"] = entry["path"] + + # Normalizar (Thread) + norm = await asyncio.to_thread( + normalize_to_schema, + data, paths=selected_fields, profile_name=prof.get('name', 'invoice') + ) + + if isinstance(norm, dict): + n_usage = norm.get("_usage") + self._report_usage(on_usage, entry['radicado'], "normalize", n_usage) + + normalized_obj = norm.get("normalized") + if isinstance(normalized_obj, dict): + data = normalized_obj + data['radicado'] = entry['radicado'] + data["_source_pdf"] = entry["path"] + + if csv_output_path: + async with csv_lock: + await asyncio.to_thread( + self._write_csv_singledoc, + csv_output_path, data, selected_fields, prof, csv_header, alias_map, entry['radicado'] + ) + + return data + + except openai.RateLimitError as e: + if on_error_retry: + # Pausar todos los demás + self._pause_event.clear() + try: + should_retry = await on_error_retry(e) + if should_retry: + self._pause_event.set() + continue # Reintentar este radicado + else: + self._stop_requested = True + self._pause_event.set() + return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path']) + except Exception as e_retry: + self._pause_event.set() + return self._handle_error_async(entry['radicado'], e_retry, csv_output_path, csv_header, entry['path']) + else: + return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path']) + except Exception as e: + return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path']) + finally: + async with progress_lock: + completed_count += 1 + self._notify_progress(on_progress, entry['radicado'], completed_count, total) + break # Salir del bucle si no hubo RateLimitError o se manejó + + tasks = [process_entry(i, e) for i, e in enumerate(entries, start=1)] + resultados = await asyncio.gather(*tasks) + return list(resultados) + + # --- Helpers --- + + def _prepare_csv_header(self, fields, alias_map, override): + if override: + return override + header_base = ['radicado'] + list(fields or []) + if not alias_map: + return header_base + + alias_map2 = dict(alias_map) + # Expandir alias items + for orig_key, alias_value in list(alias_map.items()): + if "/*/" in orig_key: + sub = orig_key.split("/*/")[-1] + if sub and sub not in alias_map2: + alias_map2[sub] = alias_value + + raw_header = [alias_map2.get(h, h) for h in header_base] + # Deduplicar conservando orden + seen = set() + final_header = [] + for h in raw_header: + if h not in seen: + final_header.append(h) + seen.add(h) + return final_header + + def _notify_progress(self, callback, rad, idx, total): + if callback: + try: + callback(rad, idx, total) + except Exception: + pass + + def _accumulate_usage(self, agg, new_usage): + if not isinstance(new_usage, dict): return + agg["prompt_tokens"] += int(new_usage.get("prompt_tokens") or 0) + agg["completion_tokens"] += int(new_usage.get("completion_tokens") or 0) + details = new_usage.get("prompt_tokens_details") or {} + agg["prompt_tokens_details"]["cached_tokens"] += int(details.get("cached_tokens") or 0) + + def _report_usage(self, callback, rad, phase, usage): + if callback and usage: + try: + model = None + if phase == "extract": + model = getattr(self.service.extractor, "model", None) + elif phase == "normalize": + # El normalizador está hardcodeado a gpt-4.1-mini en normalizer.py + model = "gpt-4.1-mini" + + callback(phase, rad, usage, compute_cost_from_usage(usage, phase, model=model)) + except Exception: + pass + + def _write_csv_multidoc(self, path, data, fields, doc_types, prof, header, alias_map, rad): + try: + agg_base = prof.get('aggregate_list_paths') or None + agg_paths_expanded = [] + if agg_base: + for base in agg_base: + for dt in doc_types: + tname = str(dt.get("name") or "").strip() or "Tipo" + agg_paths_expanded.append(f"{tname}/{base}") + + rows, computed_header = rows_from_data( + data, + aggregate_list_paths=agg_paths_expanded or None, + allowed_paths=fields, + ) + final_header = header if header else computed_header + final_rows = self._apply_alias_to_rows(rows, alias_map) + self.csv_manager.append_rows(path, final_rows, final_header) + except Exception: + logger.exception(f"Error escribiendo CSV para radicado {rad}", extra={"radicado": rad}) + + def _write_csv_singledoc(self, path, data, fields, prof, header, alias_map, rad): + try: + rows, computed_header = rows_from_data( + data, + aggregate_list_paths=(prof.get('aggregate_list_paths') or None), + allowed_paths=fields, + ) + final_header = header if header else computed_header + final_rows = self._apply_alias_to_rows(rows, alias_map) + self.csv_manager.append_rows(path, final_rows, final_header) + except Exception: + logger.exception(f"Error escribiendo CSV para radicado {rad}", extra={"radicado": rad}) + + def _apply_alias_to_rows(self, rows, alias_map): + if not alias_map: return rows + alias_map2 = dict(alias_map) + for orig_key, alias_value in list(alias_map.items()): + if "/*/" in orig_key: + sub = orig_key.split("/*/")[-1] + if sub and sub not in alias_map2: + alias_map2[sub] = alias_value + + aliased_rows = [] + for r in rows: + if isinstance(r, dict): + new_r = {} + for k, v in r.items(): + new_k = alias_map2.get(k, k) + + if new_k in new_r: + # Lógica de fusión solicitada: + # 1. Si son iguales, dejar uno. + # 2. Si son diferentes, separar por coma. + prev_val = new_r[new_k] + + # Normalizar a string para comparación y fusión + s_prev = str(prev_val).strip() if prev_val is not None else "" + s_curr = str(v).strip() if v is not None else "" + + if s_prev == s_curr: + continue + + if not s_prev: + new_r[new_k] = v + elif not s_curr: + pass + else: + new_r[new_k] = f"{s_prev}, {s_curr}" + else: + new_r[new_k] = v + aliased_rows.append(new_r) + else: + aliased_rows.append(r) + return aliased_rows + + def _handle_error(self, resultados, rad, e, csv_path, header, pdf_path=None): + error_obj = { + "radicado": rad, + "error": str(e) + } + if pdf_path: error_obj["_source_pdf"] = pdf_path + resultados.append(error_obj) + logger.exception(f"Error procesando radicado {rad}", extra={"radicado": rad}) + if csv_path: + header_err = header if header else ["radicado", "error"] + self.csv_manager.append_rows(csv_path, [error_obj], header_err) + + def _handle_error_async(self, rad, e, csv_path, header, pdf_path=None): + error_obj = { + "radicado": rad, + "error": str(e) + } + if pdf_path: error_obj["_source_pdf"] = pdf_path + logger.exception(f"Error procesando radicado {rad}", extra={"radicado": rad}) + + if csv_path: + header_err = header if header else ["radicado", "error"] + try: + self.csv_manager.append_rows(csv_path, [error_obj], header_err) + except Exception: + pass + return error_obj diff --git a/application/config.py b/application/config.py new file mode 100644 index 0000000..2de07ab --- /dev/null +++ b/application/config.py @@ -0,0 +1,67 @@ +"""Configuración centralizada de la aplicación. + +Usa Pydantic BaseSettings para cargar valores por defecto y variables de entorno. +Se lee automáticamente un archivo `.env` en la raíz del proyecto si existe. +""" +from __future__ import annotations + +from typing import Optional, Tuple +import logging +import os + +# Cargar variables desde .env en el entorno (para que otros módulos que usan os.environ las vean) +try: + from dotenv import load_dotenv # type: ignore + # En desarrollo, dar precedencia al archivo .env sobre variables previas del entorno + load_dotenv(override=True) +except Exception: + pass + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import AliasChoices + + +class ApiSettings(BaseSettings): + """Parámetros del backend compatible con OpenAI/Ollama (usando os.getenv).""" + base_url: str = os.getenv("EXTRACTOR_BASE_URL", "http://10.8.0.4:11434/v1") + api_key: str = os.getenv("EXTRACTOR_API_KEY", "ollama") + model: str = os.getenv("EXTRACTOR_MODEL", "gpt-4.1") + request_timeout_s: int = int(os.getenv("EXTRACTOR_TIMEOUT_S", 120)) + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +class ImagingSettings(BaseSettings): + """Parámetros de conversión de documentos a imágenes.""" + poppler_path: Optional[str] = Field(None, validation_alias=AliasChoices("POPPLER_PATH")) + pdf_dpi: int = Field(600, validation_alias=AliasChoices("PDF_DPI")) + pdf_thread_count: int = Field(10, validation_alias=AliasChoices("PDF_THREAD_COUNT")) + tiff_pages: Optional[Tuple[int, int]] = Field((2, 3), validation_alias=AliasChoices("TIFF_PAGES")) + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +class RetrySettings(BaseSettings): + """Estrategia de reintentos para llamadas al backend.""" + max_attempts: int = Field(3, validation_alias=AliasChoices("RETRY_MAX_ATTEMPTS")) + wait_min_s: float = Field(1.0, validation_alias=AliasChoices("RETRY_WAIT_MIN_S")) + wait_max_s: float = Field(8.0, validation_alias=AliasChoices("RETRY_WAIT_MAX_S")) + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +class ProcessingSettings(BaseSettings): + """Configuración de procesamiento y concurrencia.""" + concurrency_limit: int = Field(20, validation_alias=AliasChoices("CONCURRENCY_LIMIT")) + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +class AppSettings(BaseSettings): + """Configuración raíz agrupada.""" + api: ApiSettings = ApiSettings() + imaging: ImagingSettings = ImagingSettings() + retry: RetrySettings = RetrySettings() + processing: ProcessingSettings = ProcessingSettings() + csv_delimiter: str = Field("|", validation_alias=AliasChoices("CSV_DELIMITER")) + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +settings = AppSettings() diff --git a/application/costs.py b/application/costs.py new file mode 100644 index 0000000..a3b4bc5 --- /dev/null +++ b/application/costs.py @@ -0,0 +1,98 @@ +"""Utilidades para el cálculo de costos de tokens. + +Define precios por modelo y funciones para estimar costos a partir del uso de tokens +(prompt, completion, cached). +""" +from typing import Dict, Any, Optional + +# Precios actualizados por 1M tokens (USD) - Enero 2026 +MODEL_PRICING = { + "gpt-4.1": { + "input": 2.00, + "cached_input": 0.50, + "output": 8.00, + }, + "gpt-4.1-mini": { + "input": 0.40, + "cached_input": 0.10, + "output": 1.60, + }, + "gpt-4.1-nano": { + "input": 0.10, + "cached_input": 0.03, + "output": 0.40, + }, + "gpt-5.1": { + "input": 1.25, + "cached_input": 0.13, + "output": 10.00, + } +} + +# Legacy mapping o fase por defecto (si no se especifica modelo) +PRICING = { + "extract": MODEL_PRICING["gpt-4.1"], + "normalize": MODEL_PRICING["gpt-4.1-mini"], +} + + +def compute_cost_from_usage( + usage: Dict[str, Any] | None, + phase: str = "extract", + model: Optional[str] = None +) -> Dict[str, Any] | None: + """Calcula el costo estimado basado en el uso de tokens, la fase o el modelo específico.""" + if not isinstance(usage, dict): + return None + + prompt_tokens = int(usage.get("prompt_tokens") or 0) + completion_tokens = int(usage.get("completion_tokens") or 0) + + cached_tokens = 0 + try: + details = usage.get("prompt_tokens_details") or {} + cached_tokens = int(details.get("cached_tokens") or 0) + except Exception: + cached_tokens = 0 + + non_cached_prompt = max(prompt_tokens - cached_tokens, 0) + + # Determinar tarifas + rates = None + if model and model in MODEL_PRICING: + rates = MODEL_PRICING[model] + else: + # Fallback a fase (backward compatibility) + rates = PRICING.get(phase, PRICING["extract"]) + + rate_in = rates["input"] + rate_cached = rates["cached_input"] + rate_out = rates["output"] + + cost_input = (non_cached_prompt / 1_000_000.0) * rate_in + cost_cached = (cached_tokens / 1_000_000.0) * rate_cached + cost_output = (completion_tokens / 1_000_000.0) * rate_out + + return { + "input_usd": round(cost_input, 6), + "cached_input_usd": round(cost_cached, 6), + "output_usd": round(cost_output, 6), + "total_usd": round(cost_input + cost_cached + cost_output, 6), + } + + +def sum_costs(a: Dict[str, Any] | None, b: Dict[str, Any] | None) -> Dict[str, Any]: + """Suma dos objetos de costos.""" + def safe(x): return float(x) if x is not None else 0.0 + + input_usd = safe((a or {}).get("input_usd")) + safe((b or {}).get("input_usd")) + cached_usd = safe((a or {}).get("cached_input_usd")) + safe((b or {}).get("cached_input_usd")) + output_usd = safe((a or {}).get("output_usd")) + safe((b or {}).get("output_usd")) + + total = { + "input_usd": round(input_usd, 6), + "cached_input_usd": round(cached_usd, 6), + "output_usd": round(output_usd, 6), + } + total["total_usd"] = round(total["input_usd"] + total["cached_input_usd"] + total["output_usd"], 6) + return total diff --git a/application/csv_manager.py b/application/csv_manager.py new file mode 100644 index 0000000..a288517 --- /dev/null +++ b/application/csv_manager.py @@ -0,0 +1,26 @@ +"""Gestor de escritura CSV. + +Maneja la escritura incremental segura (con lock) en archivos CSV. +""" +import os +import csv +import threading +from typing import List, Dict, Any, Optional + +class CSVManager: + def __init__(self): + self._lock = threading.Lock() + + def append_rows(self, csv_path: str, rows: List[Dict[str, Any]], header: List[str]) -> None: + """Anexa filas a un CSV, creando encabezado si el archivo no existe.""" + os.makedirs(os.path.dirname(csv_path) or ".", exist_ok=True) + file_exists = os.path.exists(csv_path) and os.path.getsize(csv_path) > 0 + + with self._lock: + with open(csv_path, "a", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=header, extrasaction="ignore", delimiter='|') + if not file_exists: + writer.writeheader() + for r in rows: + writer.writerow(r) + diff --git a/application/database.py b/application/database.py new file mode 100644 index 0000000..b0ceaf9 --- /dev/null +++ b/application/database.py @@ -0,0 +1,182 @@ +import sqlite3 +import json +import logging +from datetime import datetime +from typing import List, Dict, Any, Optional +import os + +logger = logging.getLogger(__name__) + +class DatabaseManager: + def __init__(self, db_path: str = "preclassification.db"): + self.db_path = db_path + self._init_db() + + def _init_db(self): + """Inicializa las tablas si no existen.""" + try: + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + + # Tabla de ejecuciones + c.execute(''' + CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + root_path TEXT NOT NULL, + config TEXT + ) + ''') + + # Tabla de resultados por archivo/radicado + c.execute(''' + CREATE TABLE IF NOT EXISTS results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + radicado TEXT NOT NULL, + file_path TEXT, + status TEXT NOT NULL, -- 'MATCH', 'NO_MATCH', 'ERROR' + raw_response TEXT, + FOREIGN KEY(run_id) REFERENCES runs(id) + ) + ''') + + # Tabla de coincidencias detalladas (campo por campo) + c.execute(''' + CREATE TABLE IF NOT EXISTS matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + result_id INTEGER NOT NULL, + key TEXT NOT NULL, + expected_value TEXT, + found_value TEXT, + exists_bool BOOLEAN NOT NULL, + FOREIGN KEY(result_id) REFERENCES results(id) + ) + ''') + + conn.commit() + conn.close() + except Exception as e: + logger.error(f"Error inicializando base de datos: {e}") + + def create_run(self, root_path: str, config: Dict[str, Any]) -> int: + """Crea una nueva ejecución y devuelve su ID.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + timestamp = datetime.now().isoformat() + c.execute('INSERT INTO runs (timestamp, root_path, config) VALUES (?, ?, ?)', + (timestamp, root_path, json.dumps(config))) + run_id = c.lastrowid + conn.commit() + conn.close() + return run_id + + def add_result(self, run_id: int, radicado: str, file_path: str, status: str, raw_response: str) -> int: + """Añade un resultado de archivo procesado y devuelve su ID.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute('INSERT INTO results (run_id, radicado, file_path, status, raw_response) VALUES (?, ?, ?, ?, ?)', + (run_id, radicado, file_path, status, raw_response)) + result_id = c.lastrowid + conn.commit() + conn.close() + return result_id + + def add_match(self, result_id: int, key: str, expected_value: Optional[str], found_value: Optional[str], exists: bool): + """Añade el detalle de un campo encontrado (o no).""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute('INSERT INTO matches (result_id, key, expected_value, found_value, exists_bool) VALUES (?, ?, ?, ?, ?)', + (result_id, key, expected_value, found_value, exists)) + conn.commit() + conn.close() + + def get_run_results(self, run_id: int) -> List[Dict[str, Any]]: + """Obtiene resultados completos de una ejecución.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + c.execute(''' + SELECT r.radicado, r.file_path, r.status, m.key, m.expected_value, m.found_value, m.exists_bool + FROM results r + LEFT JOIN matches m ON r.id = m.result_id + WHERE r.run_id = ? + ''', (run_id,)) + + rows = c.fetchall() + conn.close() + return [dict(row) for row in rows] + + def get_all_runs(self) -> List[Dict[str, Any]]: + """Obtiene lista de todos los runs ordenados por fecha descendente.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT * FROM runs ORDER BY timestamp DESC") + rows = c.fetchall() + conn.close() + return [dict(row) for row in rows] + + def search_results(self, run_id: Optional[int] = None, status: Optional[str] = None, text: Optional[str] = None) -> List[Dict[str, Any]]: + """Busca resultados con filtros.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + query = ''' + SELECT r.id, r.run_id, r.radicado, r.file_path, r.status, r.raw_response, runs.timestamp + FROM results r + JOIN runs ON r.run_id = runs.id + WHERE 1=1 + ''' + params = [] + + if run_id: + query += " AND r.run_id = ?" + params.append(run_id) + + if status and status != "Todos": + query += " AND r.status = ?" + params.append(status) + + if text: + # Busqueda simple en radicado o path + query += " AND (r.radicado LIKE ? OR r.file_path LIKE ?)" + wild = f"%{text}%" + params.extend([wild, wild]) + + query += " ORDER BY r.id DESC LIMIT 1000" + + c.execute(query, params) + rows = c.fetchall() + conn.close() + return [dict(row) for row in rows] + + def get_matches_for_result(self, result_id: int) -> List[Dict[str, Any]]: + """Obtiene el detalle de matches para un resultado específico.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT * FROM matches WHERE result_id = ?", (result_id,)) + rows = c.fetchall() + conn.close() + return [dict(row) for row in rows] + + def get_run(self, run_id: int) -> Optional[Dict[str, Any]]: + """Obtiene un run por ID (incluye config como dict si aplica).""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT * FROM runs WHERE id = ?", (run_id,)) + row = c.fetchone() + conn.close() + if not row: + return None + d = dict(row) + try: + cfg_raw = d.get("config") + d["config"] = json.loads(cfg_raw) if cfg_raw else {} + except Exception: + d["config"] = {} + return d diff --git a/application/estimator.py b/application/estimator.py new file mode 100644 index 0000000..ca9112c --- /dev/null +++ b/application/estimator.py @@ -0,0 +1,129 @@ +"""Estimador de costos y tokens. + +Encapsula la lógica de estimación previa al procesamiento. +""" +import os +from typing import List, Dict, Any, Optional + +from application.profiles import get_profile +from application.prompts import PromptBuilder +from application.normalizer import normalize_to_schema +from application.costs import compute_cost_from_usage, sum_costs +from application.file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados +from core.utils import collect_radicado_docs, collect_documents_flat + +class Estimator: + def __init__(self, service): + self.service = service + + def estimate( + self, + root_path: str, + keywords: List[str] | None = None, + schema: Dict[str, str] | None = None, + selected_fields: List[str] | None = None, + *, + doc_types: Optional[List[Dict[str, str]]] = None, + schema_by_type: Optional[Dict[str, Dict[str, str]]] = None, + selected_paths: Optional[List[str]] = None, + structure_mode: str = "radicados", + ) -> Dict[str, Any]: + """Calcula estimado de tokens y costos para toda la carpeta basado en el primer documento.""" + if doc_types: + return self._estimate_multidoc(root_path, doc_types, schema_by_type, selected_paths, structure_mode) + return self._estimate_singledoc(root_path, keywords, schema, selected_fields, structure_mode) + + def _estimate_multidoc(self, root_path, doc_types, schema_by_type, selected_paths, structure_mode="radicados"): + if structure_mode == "general": + radicados = get_files_as_radicados(root_path) + else: + radicados = get_sorted_radicados(root_path) + + if not radicados: + return {"total_docs": 0, "per_doc": {}, "estimated_total": {}} + + prof = get_profile('invoice') + first_radicado_found = None + + agg_extract_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}} + agg_normalize_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}} + + for dt in doc_types: + tname = str(dt.get("name") or "").strip() or "Tipo" + kw = str(dt.get("keyword") or "").strip() + + match, rad_used = find_file_for_type_in_radicados(root_path, radicados, kw) + if not match: continue + + if first_radicado_found is None: + first_radicado_found = rad_used + + sel_all = selected_paths or [] + selected_paths_for_type = [p.split('/', 1)[1] for p in sel_all if p.startswith(f"{tname}/")] + sch_t = (schema_by_type or {}).get(tname) or {} + + try: + prompt_t = PromptBuilder.build_for_type(tname, selected_paths_for_type, sch_t, profile_name=prof.get('name', 'invoice')) + except Exception: + prompt_t = PromptBuilder.build(selected_paths_for_type, sch_t, profile_name=prof.get('name', 'invoice')) + + data, usage_ext = self.service.extract_full_from_pdf(match, instruction=prompt_t) + self._accumulate_usage(agg_extract_usage, usage_ext) + + if isinstance(data, dict): data['radicado'] = rad_used + else: data = {"radicado": rad_used, "data": data} + + norm = normalize_to_schema(data, paths=selected_paths_for_type, profile_name=prof.get('name', 'invoice')) + if isinstance(norm, dict): + self._accumulate_usage(agg_normalize_usage, norm.get("_usage")) + + return self._build_result(agg_extract_usage, agg_normalize_usage, len(radicados), first_radicado_found or radicados[0]) + + def _estimate_singledoc(self, root_path, keywords, schema, selected_fields, structure_mode="radicados"): + if structure_mode == "general": + entries = collect_documents_flat(root_path, keywords) + else: + entries = collect_radicado_docs(root_path, keywords or []) + + if not entries: + return {"total_docs": 0, "per_doc": {}, "estimated_total": {}} + + first = entries[0] + prof = get_profile('invoice') + prompt = PromptBuilder.build(selected_fields or [], schema or {}) + + data, usage_ext = self.service.extract_full_from_pdf(first["path"], instruction=prompt) + if isinstance(data, dict): data['radicado'] = first['radicado'] + else: data = {"radicado": first['radicado'], "data": data} + + norm = normalize_to_schema(data, paths=selected_fields, profile_name=prof.get('name', 'invoice')) + usage_norm = None + if isinstance(norm, dict): + usage_norm = norm.get("_usage") + + return self._build_result(usage_ext, usage_norm, len(entries), first["radicado"]) + + def _accumulate_usage(self, agg, new_usage): + if not isinstance(new_usage, dict): return + agg["prompt_tokens"] += int(new_usage.get("prompt_tokens") or 0) + agg["completion_tokens"] += int(new_usage.get("completion_tokens") or 0) + details = new_usage.get("prompt_tokens_details") or {} + agg["prompt_tokens_details"]["cached_tokens"] += int(details.get("cached_tokens") or 0) + + def _build_result(self, usage_ext, usage_norm, total_docs, first_radicado): + model_ext = getattr(self.service.extractor, "model", None) + cost_ext = compute_cost_from_usage(usage_ext, "extract", model=model_ext) + # El normalizador está hardcodeado a gpt-4.1-mini en normalizer.py + cost_norm = compute_cost_from_usage(usage_norm, "normalize", model="gpt-4.1-mini") + per_doc_cost = sum_costs(cost_ext, cost_norm) + estimated_total = {k: round(v * total_docs, 6) for k, v in per_doc_cost.items()} + + return { + "total_docs": total_docs, + "first_radicado": first_radicado, + "extractor_usage": usage_ext, + "normalizer_usage": usage_norm, + "per_doc_cost": per_doc_cost, + "estimated_total_cost": estimated_total, + } + diff --git a/application/file_discovery.py b/application/file_discovery.py new file mode 100644 index 0000000..273fb37 --- /dev/null +++ b/application/file_discovery.py @@ -0,0 +1,64 @@ +"""Lógica de descubrimiento de archivos y radicados. + +Separa la lógica de recorrer el sistema de archivos del servicio principal. +""" +import os +from typing import List, Optional, Tuple +from core.utils import find_first_matching_file, ALLOWED_DOC_EXTS + + +def get_sorted_radicados(root_path: str) -> List[str]: + """Devuelve una lista ordenada de nombres de subdirectorios (radicados) en root_path.""" + if not os.path.isdir(root_path): + return [] + return sorted([d for d in os.listdir(root_path) if os.path.isdir(os.path.join(root_path, d))]) + + +def get_files_as_radicados(root_path: str) -> List[str]: + """Devuelve una lista de nombres de archivos (sin extensión) que actúan como radicados.""" + if not os.path.isdir(root_path): + return [] + files = sorted([f for f in os.listdir(root_path) if f.lower().endswith(ALLOWED_DOC_EXTS)]) + # Retornamos el nombre completo del archivo para que el procesador pueda encontrarlo, + # O retornamos el stem y luego reconstruimos? + # El procesador espera "radicado" que luego usa para os.path.join(root, rad). + # Si retornamos el nombre de archivo, os.path.join funcionará para encontrar el archivo. + # Pero el "radicado" visual debe ser sin extensión. + # Vamos a retornar el nombre de archivo completo aquí, y el procesador deberá manejarlo. + return files + + +def find_file_for_type_in_radicados( + root_path: str, + radicados: List[str], + keyword: str +) -> Tuple[Optional[str], Optional[str]]: + """Busca el primer archivo que coincida con keyword en la lista de radicados. + + Retorna: (ruta_completa_del_archivo, nombre_del_radicado) + """ + for rad in radicados: + rad_path = os.path.join(root_path, rad) + + # Si rad es un archivo (modo General), verificamos si coincide + if os.path.isfile(rad_path): + # En modo general, 'rad' es el nombre del archivo. + # Si hay keyword, verificamos si el archivo coincide con el keyword. + # Pero esta función se usa para "Multi-Doc Type", donde buscamos UN archivo para UN tipo. + # Si estamos en modo general, el archivo ES el documento. + # Simplemente retornamos el archivo si coincide con el keyword (o si no hay keyword). + from core.utils import _matches_pattern + if not keyword or _matches_pattern(rad, keyword): + return rad_path, os.path.splitext(rad)[0] + continue + + try: + files = sorted(os.listdir(rad_path)) + except Exception: + continue + + match_name = find_first_matching_file(files, keyword) if keyword else None + if match_name: + return os.path.join(rad_path, match_name), rad + + return None, None diff --git a/application/llm_client.py b/application/llm_client.py new file mode 100644 index 0000000..251eae5 --- /dev/null +++ b/application/llm_client.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +import time +import logging + +from openai import OpenAI, AsyncOpenAI +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type + +from .config import settings + + +class LLMClient: + """Cliente unificado para llamadas Chat Completions (SDK OpenAI). + + - Centraliza api_key, base_url, modelo y timeout desde `settings`. + - Expone helpers para respuestas de texto y JSON. + - Registra métricas mínimas en `application.api_calls` (start/end/error + usage). + - Aplica reintentos ante timeouts. + """ + + def __init__( + self, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + default_model: Optional[str] = None, + request_timeout_s: Optional[float] = None, + ) -> None: + self.api_key = api_key or settings.api.api_key + self.base_url = base_url or settings.api.base_url + self.default_model = default_model or settings.api.model + self.request_timeout_s = request_timeout_s or settings.api.request_timeout_s + self._client = OpenAI(api_key=self.api_key, base_url=self.base_url) + self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url) + self._api_logger = logging.getLogger("application.api_calls") + + def update_config( + self, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + default_model: Optional[str] = None, + request_timeout_s: Optional[float] = None, + ) -> None: + if api_key is not None: + self.api_key = api_key + if base_url is not None: + self.base_url = base_url + if default_model is not None: + self.default_model = default_model + if request_timeout_s is not None: + self.request_timeout_s = request_timeout_s + self._client = OpenAI(api_key=self.api_key, base_url=self.base_url) + self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url) + + @retry( + stop=stop_after_attempt(settings.retry.max_attempts), + wait=wait_exponential(multiplier=settings.retry.wait_min_s, max=settings.retry.wait_max_s), + retry=retry_if_exception_type((TimeoutError,)), + reraise=True, + ) + def _create_completion(self, *, messages: List[Dict[str, Any]], model: str, timeout_s: float, **extra) -> Any: + return self._client.chat.completions.create( + messages=messages, + model=model, + timeout=timeout_s, + **extra, + ) + + @retry( + stop=stop_after_attempt(settings.retry.max_attempts), + wait=wait_exponential(multiplier=settings.retry.wait_min_s, max=settings.retry.wait_max_s), + retry=retry_if_exception_type((TimeoutError,)), + reraise=True, + ) + async def _create_completion_async(self, *, messages: List[Dict[str, Any]], model: str, timeout_s: float, **extra) -> Any: + return await self._async_client.chat.completions.create( + messages=messages, + model=model, + timeout=timeout_s, + **extra, + ) + + def _log_start(self, *, component: str, model: str, timeout_s: float, messages_count: int) -> None: + try: + self._api_logger.info( + "api_call_start", + extra={ + "component": component, + "base_url": self.base_url, + "model": model, + "timeout_s": timeout_s, + "messages": messages_count, + }, + ) + except Exception: + pass + + def _log_end(self, *, component: str, model: str, timeout_s: float, elapsed_ms: int, usage: Optional[Dict[str, Any]]) -> None: + try: + self._api_logger.info( + "api_call_end", + extra={ + "component": component, + "base_url": self.base_url, + "model": model, + "timeout_s": timeout_s, + "elapsed_ms": elapsed_ms, + "prompt_tokens": (usage or {}).get("prompt_tokens"), + "completion_tokens": (usage or {}).get("completion_tokens"), + "total_tokens": (usage or {}).get("total_tokens"), + }, + ) + except Exception: + pass + + def _log_error(self, *, component: str, model: str, timeout_s: float, elapsed_ms: Optional[int], error: Exception) -> None: + try: + self._api_logger.info( + "api_call_error", + extra={ + "component": component, + "base_url": self.base_url, + "model": model, + "timeout_s": timeout_s, + "elapsed_ms": (elapsed_ms if elapsed_ms is not None else None), + "error": str(error), + }, + ) + except Exception: + pass + + @staticmethod + def _extract_usage(obj: Any) -> Optional[Dict[str, Any]]: + try: + usage = getattr(obj, "usage", None) + if usage is None: + return None + usage_dict: Dict[str, Any] = { + "prompt_tokens": getattr(usage, "prompt_tokens", None), + "completion_tokens": getattr(usage, "completion_tokens", None), + "total_tokens": getattr(usage, "total_tokens", None), + } + details = getattr(usage, "prompt_tokens_details", None) + if details is not None: + try: + usage_dict["prompt_tokens_details"] = dict(details) # type: ignore[arg-type] + except Exception: + try: + usage_dict["prompt_tokens_details"] = { + "cached_tokens": getattr(details, "cached_tokens", None), + "audio_tokens": getattr(details, "audio_tokens", None), + } + except Exception: + pass + return usage_dict + except Exception: + return None + + def chat_text( + self, + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + timeout_s: Optional[float] = None, + component: str = "llm_client", + ) -> Tuple[str, Optional[Dict[str, Any]]]: + """Devuelve (texto, usage).""" + eff_model = model or self.default_model + eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) + self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) + start = time.time() + try: + chat_completion = self._create_completion( + messages=messages, + model=eff_model, + timeout_s=eff_timeout, + ) + elapsed_ms = int((time.time() - start) * 1000) + usage = self._extract_usage(chat_completion) + self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) + text: str = chat_completion.choices[0].message.content # type: ignore[assignment] + return text, usage + except Exception as ex: + elapsed_ms = int((time.time() - start) * 1000) + self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) + raise + + async def chat_text_async( + self, + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + timeout_s: Optional[float] = None, + component: str = "llm_client", + ) -> Tuple[str, Optional[Dict[str, Any]]]: + """Devuelve (texto, usage) de forma asíncrona.""" + eff_model = model or self.default_model + eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) + self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) + start = time.time() + try: + chat_completion = await self._create_completion_async( + messages=messages, + model=eff_model, + timeout_s=eff_timeout, + ) + elapsed_ms = int((time.time() - start) * 1000) + usage = self._extract_usage(chat_completion) + self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) + text: str = chat_completion.choices[0].message.content # type: ignore[assignment] + return text, usage + except Exception as ex: + elapsed_ms = int((time.time() - start) * 1000) + self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) + raise + + def chat_json( + self, + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + response_format: str = "json_object", + timeout_s: Optional[float] = None, + component: str = "llm_client", + ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + """Devuelve (objeto JSON parseado, usage).""" + import json + eff_model = model or self.default_model + eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) + self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) + start = time.time() + try: + chat_completion = self._create_completion( + messages=messages, + model=eff_model, + timeout_s=eff_timeout, + response_format={"type": response_format}, + ) + elapsed_ms = int((time.time() - start) * 1000) + usage = self._extract_usage(chat_completion) + self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) + content = chat_completion.choices[0].message.content # type: ignore[assignment] + obj = json.loads(content) + return obj, usage + except Exception as ex: + elapsed_ms = int((time.time() - start) * 1000) + self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) + raise + + async def chat_json_async( + self, + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + response_format: str = "json_object", + timeout_s: Optional[float] = None, + component: str = "llm_client", + ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + """Devuelve (objeto JSON parseado, usage) de forma asíncrona.""" + import json + eff_model = model or self.default_model + eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) + self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) + start = time.time() + try: + chat_completion = await self._create_completion_async( + messages=messages, + model=eff_model, + timeout_s=eff_timeout, + response_format={"type": response_format}, + ) + elapsed_ms = int((time.time() - start) * 1000) + usage = self._extract_usage(chat_completion) + self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) + content = chat_completion.choices[0].message.content # type: ignore[assignment] + obj = json.loads(content) + return obj, usage + except Exception as ex: + elapsed_ms = int((time.time() - start) * 1000) + self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) + raise diff --git a/application/logging_config.py b/application/logging_config.py new file mode 100644 index 0000000..208427c --- /dev/null +++ b/application/logging_config.py @@ -0,0 +1,182 @@ +"""Configuración de logging en formato JSON y loggers dedicados. + +Crea handlers rotativos para: +- logs de aplicación (`logs/ips_extractor.log`), +- logs de normalización LLM (`logs/llm.log`), +- logs de llamadas a APIs (`logs/api_calls.log`), +además de un stream a consola para WARNING+. +""" +from __future__ import annotations + +import json +import logging +import logging.handlers +import os +import time +from datetime import datetime +from functools import wraps +from typing import Callable, Any +import coloredlogs + + +class JsonFormatter(logging.Formatter): + """Formatter que serializa registros a JSON con extras.""" + def format(self, record: logging.LogRecord) -> str: + base = { + "ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z", + "level": record.levelname, + "logger": record.name, + "msg": record.getMessage(), + } + # incluir extras no estándar + extras = {} + for k, v in record.__dict__.items(): + if k in ( + "name", "msg", "args", "levelname", "levelno", "pathname", "filename", + "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", + "created", "msecs", "relativeCreated", "thread", "threadName", "processName", + "process", "message", "asctime", + ): + continue + # evitar objetos no serializables + try: + json.dumps(v) + extras[k] = v + except Exception: + extras[k] = str(v) + if extras: + base.update(extras) + return json.dumps(base, ensure_ascii=False) + + +_configured = False + + +def log_execution_time(logger: logging.Logger | None = None, level: int = logging.DEBUG) -> Callable: + """Decorador para registrar el tiempo de ejecución de una función.""" + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + # Si no se pasa logger, intentar obtenerlo del primer argumento (self) si es una clase + # o usar el logger del módulo de la función. + _logger = logger + if _logger is None: + if args and hasattr(args[0], '__class__'): + # Intenta buscar un logger en la instancia + _logger = getattr(args[0], 'logger', None) + + if _logger is None: + _logger = logging.getLogger(func.__module__) + + start_time = time.time() + try: + result = func(*args, **kwargs) + return result + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + if _logger: + _logger.exception( + f"Error en ejecución de {func.__name__}", + extra={"duration_ms": duration_ms, "function": func.__name__, "error": str(e)} + ) + raise + finally: + duration_ms = (time.time() - start_time) * 1000 + if _logger: + _logger.log( + level, + f"Ejecución de {func.__name__} completada", + extra={"duration_ms": duration_ms, "function": func.__name__} + ) + return wrapper + return decorator + + +def configure_logging(level: int = logging.INFO) -> None: + """Configura logging global de forma idempotente. + + - Crea archivos rotativos en `logs/` con formato JSON. + - Configura salida a consola con colores y formato legible. + - Define loggers dedicados para normalización LLM y llamadas a APIs. + """ + global _configured + if _configured: + return + os.makedirs("logs", exist_ok=True) + + root = logging.getLogger() + # idempotencia: limpiar handlers existentes solo si fueron nuestros json + # si ya hay handlers, no duplicar + if not root.handlers: + root.setLevel(level) + + # Handler de archivo principal (JSON) + app_handler = logging.handlers.RotatingFileHandler( + filename=os.path.join("logs", "ips_extractor.log"), + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + app_handler.setFormatter(JsonFormatter()) + app_handler.setLevel(logging.INFO) + root.addHandler(app_handler) + + # Handler de consola (Colores y legible para humanos) + console = logging.StreamHandler() + console.setLevel(level) # Usar el nivel configurado (INFO por defecto) + + # Formato de consola: Tiempo | Nivel | Logger | Mensaje + console_fmt = "%(asctime)s %(levelname)-8s %(name)s %(message)s" + console_formatter = coloredlogs.ColoredFormatter( + fmt=console_fmt, + datefmt='%H:%M:%S', + level_styles={ + 'debug': {'color': 'black', 'bright': True}, + 'info': {'color': 'green'}, + 'warning': {'color': 'yellow'}, + 'error': {'color': 'red'}, + 'critical': {'color': 'red', 'bold': True}, + }, + field_styles={ + 'asctime': {'color': 'cyan'}, + 'levelname': {'bold': True}, + 'name': {'color': 'blue'}, + } + ) + console.setFormatter(console_formatter) + root.addHandler(console) + + # Logger dedicado para normalizador (LLM) + llm_logger = logging.getLogger("application.normalizer") + # evitar duplicados si ya está configurado + has_file = any(isinstance(h, logging.handlers.RotatingFileHandler) for h in llm_logger.handlers) + if not has_file: + llm_handler = logging.handlers.RotatingFileHandler( + filename=os.path.join("logs", "llm.log"), + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + llm_handler.setFormatter(JsonFormatter()) + llm_handler.setLevel(logging.INFO) + llm_logger.addHandler(llm_handler) + llm_logger.setLevel(logging.INFO) + llm_logger.propagate = False # No propagar al root para no duplicar en consola/main log si no se desea + + # Logger dedicado para llamadas a APIs (peticiones y tokens) + api_calls_logger = logging.getLogger("application.api_calls") + has_api_file = any(isinstance(h, logging.handlers.RotatingFileHandler) for h in api_calls_logger.handlers) + if not has_api_file: + api_handler = logging.handlers.RotatingFileHandler( + filename=os.path.join("logs", "api_calls.log"), + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + api_handler.setFormatter(JsonFormatter()) + api_handler.setLevel(logging.INFO) + api_calls_logger.addHandler(api_handler) + api_calls_logger.setLevel(logging.INFO) + api_calls_logger.propagate = False + + _configured = True \ No newline at end of file diff --git a/application/normalizer.py b/application/normalizer.py new file mode 100644 index 0000000..c30ab0c --- /dev/null +++ b/application/normalizer.py @@ -0,0 +1,180 @@ +"""Normalizador LLM: adapta un JSON fuente al esquema canónico de rutas. + +Usa la API de OpenAI (Chat Completions) para transformar el JSON devuelto por +el extractor a un objeto que respete exactamente las rutas/campos canónicos +seleccionados por el usuario (incluyendo listas items/*). +""" +from __future__ import annotations + +import logging +import time +import os +from typing import Any, Dict, List, Optional + +from .config import settings +from .prompts import PromptBuilder +from .llm_client import LLMClient + +logger = logging.getLogger('application.normalizer') +_llm_client = LLMClient() + + +def _ensure_dict_at_path(root: Dict[str, Any], key: str) -> Dict[str, Any]: + val = root.get(key) + if not isinstance(val, dict): + val = {} + root[key] = val + return val + + +def _ensure_list_with_object(root: Dict[str, Any], key: str) -> Dict[str, Any]: + val = root.get(key) + if not isinstance(val, list) or not val or not isinstance(val[0], dict): + root[key] = [{}] + return root[key][0] + return val[0] + + +def build_canonical_template(paths: List[str]) -> Dict[str, Any]: + """Convierte rutas 'a/b/*/c' en un objeto anidado con listas y nulls. + + - Siempre incluye 'radicado' en la raíz. + - Para 'x/*/y' crea 'x': [{ 'y': null }]. Si hay más subniveles, anida en ese objeto. + """ + template: Dict[str, Any] = {"radicado": None} + for raw_path in paths or []: + if not raw_path: + continue + segments = [s for s in raw_path.split('/') if s] + if not segments: + continue + i = 0 + cur: Dict[str, Any] = template + while i < len(segments): + name = segments[i] + is_last = (i == len(segments) - 1) + # próximo segmento indica lista + if i + 1 < len(segments) and segments[i + 1] == '*': + # asegurar lista con un objeto + cur = _ensure_list_with_object(cur, name) + i += 2 # saltar el '*' + # si terminamos después del '*', no hay campo que asignar aquí + if i >= len(segments): + break + # continuar con el objeto dentro de la lista + continue + # segmento normal + if is_last: + cur[name] = None + else: + cur = _ensure_dict_at_path(cur, name) + i += 1 + return template + + +def normalize_to_schema( + data: Dict[str, Any], + paths: List[str], + profile_name: str, + model: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Usa un LLM para mapear `data` a un objeto que siga el template canónico. + + Retorna None si no hay API Key o si el LLM falla. + """ + try: + t0 = time.perf_counter() + template = build_canonical_template(paths) + # Construcción centralizada de mensajes + messages = PromptBuilder.build_normalization_messages(profile_name, template, data) + # Forzar modelo del normalizador a gpt-5-mini + eff_model = "gpt-4.1-mini" + eff_base_url = settings.api.base_url or "https://api.openai.com/v1" + eff_timeout = getattr(settings.api, "request_timeout_s", 30) + # log inicio + try: + logger.info( + "llm_normalize_start", + extra={ + "model": eff_model, + "base_url": eff_base_url, + "timeout_s": eff_timeout, + "paths_count": len(paths or []), + "template_fields": list(template.keys()), + "radicado": (data.get("radicado") if isinstance(data, dict) else None), + }, + ) + except Exception: + pass + + # Llamada centralizada vía LLMClient (JSON estricto) + try: + normalized_obj, usage = _llm_client.chat_json( + messages=messages, model=eff_model, + response_format="json_object", + timeout_s=float(eff_timeout), + component="normalizer", + ) + except Exception: + normalized_obj = None + usage = None + + if not isinstance(normalized_obj, dict): + try: + dt_ms = int((time.perf_counter() - t0) * 1000) + logger.info( + "llm_normalize_end", + extra={ + "normalized": False, + "duration_ms": dt_ms, + "model": eff_model, + "base_url": eff_base_url, + "radicado": (data.get("radicado") if isinstance(data, dict) else None), + }, + ) + except Exception: + pass + return None + # asegurar radicado en raíz si existía en data + rad = None + try: + rad = data.get("radicado") + except Exception: + rad = None + if rad is not None and isinstance(normalized_obj, dict): + normalized_obj["radicado"] = rad + try: + dt_ms = int((time.perf_counter() - t0) * 1000) + logger.info( + "llm_normalize_end", + extra={ + "normalized": True, + "duration_ms": dt_ms, + "model": eff_model, + "base_url": eff_base_url, + "radicado": rad, + }, + ) + except Exception: + pass + # Devolver solo el objeto normalizado (compatibilidad) cuando lo llame alguien antiguo + return {"normalized": normalized_obj, "_usage": usage} + except Exception as ex: + try: + dt_ms = int((time.perf_counter() - t0) * 1000) if 't0' in locals() else None + logger.info( + "llm_normalize_end", + extra={ + "normalized": False, + "error": str(ex), + "duration_ms": dt_ms, + "model": (eff_model if 'eff_model' in locals() else os.environ.get("NORMALIZE_MODEL", model or settings.api.model or "gpt-4.1-nano")), + "base_url": (eff_base_url if 'eff_base_url' in locals() else (settings.api.base_url or "https://api.openai.com/v1")), + "radicado": (data.get("radicado") if isinstance(data, dict) else None), + }, + ) + except Exception: + pass + return None + + diff --git a/application/preclassification.py b/application/preclassification.py new file mode 100644 index 0000000..5a693d5 --- /dev/null +++ b/application/preclassification.py @@ -0,0 +1,470 @@ +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() diff --git a/application/profiles.py b/application/profiles.py new file mode 100644 index 0000000..714cc29 --- /dev/null +++ b/application/profiles.py @@ -0,0 +1,34 @@ +"""Perfiles de extracción. + +Define configuraciones por tipo de documento para guiar prompts, envoltura +de resultados y agregaciones especiales en CSV. +""" +from __future__ import annotations + +from typing import Dict, List, Optional, TypedDict + + +class Profile(TypedDict, total=False): + name: str + wrap_root: Optional[str] + default_instruction: str + aggregate_list_paths: List[str] + + +DEFAULT_INVOICE_INSTRUCTION = ( + "Extrae los datos de la factura a un JSON, identificando la tabla principal por sus columnas (código, cantidad, valor unitario) para crear un array con la clave fija 'items', y usando las etiquetas literales del documento como claves para todo el resto de la información. No incluyas el campo 'observaciones' en el JSON. Ten especial cuidado en no confundir el número de autorización con el código." +) + + +PROFILES: Dict[str, Profile] = { + "invoice": { + "name": "invoice", + "wrap_root": None, + "default_instruction": DEFAULT_INVOICE_INSTRUCTION, + "aggregate_list_paths": [], + }, +} + +def get_profile(name: str) -> Profile: + key = (name or "invoice").lower() + return PROFILES.get(key, PROFILES["invoice"]) # por defecto factura \ No newline at end of file diff --git a/application/prompts.py b/application/prompts.py new file mode 100644 index 0000000..0999803 --- /dev/null +++ b/application/prompts.py @@ -0,0 +1,115 @@ +"""Builder para prompts de extracción. + +Centraliza la construcción de instrucciones reducidas en base a: +- selección de campos (paths de schema) +- schema de referencia (ruta->tipo) +- perfil (invoice/prenuevaeps) +""" +from __future__ import annotations + +import json +from typing import Dict, List, Any + +from .profiles import get_profile + + +class PromptBuilder: + @staticmethod + def build(selected_fields: List[str], schema: Dict[str, str], profile_name: str | None = None) -> str: + profile = get_profile(profile_name or "invoice") + field_schema = {k: schema[k] for k in selected_fields if k in schema} + json_prompt: Dict[str, Any] = { + "meta": { + "perfil": profile.get("name", "invoice"), + "descripcion": "Extracción estructurada desde OCR de facturas (sin datos personales reales)", + }, + "instrucciones": [ + "Extrae únicamente los campos solicitados respetando jerarquía y listas", + "Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional", + "No inventes claves nuevas ni cambies nombres existentes", + "Si un campo no existe, usa null o [] según corresponda", + "Ten especial cuidado en no confundir el número de autorización con el código" + ], + "campos_requeridos": selected_fields, + "schema_referencia": field_schema, + "reglas_estrictas_nomenclatura": [ + "Usa EXACTAMENTE los nombres listados en 'campos_requeridos'", + "No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)", + "Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}", + "No cambies nombres de claves por 'equivalentes'", + "Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado", + ], + "salida": { + "tipo": "json_object", + "raiz": None, + }, + "nota_listas": "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}", + } + return json.dumps(json_prompt, indent=2, ensure_ascii=False) + + + @staticmethod + def build_normalization_messages(profile_name: str, template: Dict[str, Any], source_json: Dict[str, Any]) -> List[Dict[str, str]]: + system_txt = ( + "Eres un asistente que normaliza un JSON a un ESQUEMA CANÓNICO dado. " + "Debes devolver EXCLUSIVAMENTE un JSON que respete exactamente la estructura y nombres del 'template'. " + "Reglas: (1) No inventes claves; (2) No uses sinónimos ni variantes; (3) Respeta listas (si 'items' es lista, devuelve un array de objetos); " + "(4) Si falta un valor, usa null o [] según corresponda; (5) No incluyas texto extra ni comentarios." + ) + user_obj = { + "perfil": profile_name, + "template": template, + "source_json": source_json, + "instrucciones": ( + "Mapea los valores del 'source_json' al 'template'. Si hay claves equivalentes en el source, " + "elige siempre los nombres del template. No devuelvas rutas con '/', sino objetos anidados." + ), + } + return [ + {"role": "system", "content": system_txt}, + {"role": "user", "content": json.dumps(user_obj, ensure_ascii=False)}, + ] + + @staticmethod + def build_for_type( + doc_type_name: str, + selected_paths_for_type: List[str], + schema_for_type: Dict[str, str], + profile_name: str | None = None, + ) -> str: + """Construye un prompt reducido para un Tipo de Documento específico. + + doc_type_name se usa en meta para trazabilidad; no afecta el esquema. + """ + profile = get_profile(profile_name or "invoice") + field_schema = {k: schema_for_type.get(k) for k in selected_paths_for_type if k in schema_for_type} + json_prompt: Dict[str, Any] = { + "meta": { + "perfil": profile.get("name", "invoice"), + "tipo_documento": doc_type_name, + "descripcion": "Extracción estructurada desde OCR (sin datos personales reales)", + }, + "instrucciones": [ + "Extrae únicamente los campos solicitados respetando jerarquía y listas", + "Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional", + "No inventes claves nuevas ni cambies nombres existentes", + "Si un campo no existe, usa null o [] según corresponda", + "Ten especial cuidado en no confundir el número de autorización con el código" + ], + "campos_requeridos": selected_paths_for_type, + "schema_referencia": field_schema, + "reglas_estrictas_nomenclatura": [ + "Usa EXACTAMENTE los nombres listados en 'campos_requeridos'", + "No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)", + "Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}", + "No cambies nombres de claves por 'equivalentes'", + "Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado", + ], + "salida": { + "tipo": "json_object", + "raiz": None, + }, + "nota_listas": "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}", + } + return json.dumps(json_prompt, indent=2, ensure_ascii=False) + diff --git a/application/service.py b/application/service.py new file mode 100644 index 0000000..4a751cf --- /dev/null +++ b/application/service.py @@ -0,0 +1,364 @@ +"""Servicios de alto nivel para el procesamiento de facturas. + +Orquesta la extracción usando una `InvoiceExtractorStrategy`, la conversión de +documentos (PDF/TIFF) a imágenes base64 y el parseo del bloque JSON. +Incluye un flujo por lotes con selección de campos y exportación incremental a CSV. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple, Callable, Awaitable +import json +import logging +import os +import time +import asyncio +from typing import List, Dict, Any, Optional, Tuple +from typing import Callable + +from .strategies import InvoiceExtractorStrategy +from .prompts import PromptBuilder +from .profiles import get_profile +from .config import settings +from .normalizer import normalize_to_schema +from .logging_config import log_execution_time +from .file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados +from .batch_processor import BatchProcessor +from .estimator import Estimator +from core.pdf_utils import document_to_images_bs64 +from core.text_utils import extract_json_from_text, build_schema +from core.utils import find_first_matching_file + +logger = logging.getLogger(__name__) + +class InvoiceProcessingService: + """Fachada de negocio para extraer y transformar datos de facturas.""" + def __init__(self, extractor: InvoiceExtractorStrategy) -> None: + self.extractor = extractor + # Componentes delegados + self.batch_processor = BatchProcessor(self) + self.estimator = Estimator(self) + + # Lock para escrituras concurrentes al mismo CSV desde múltiples hilos + # (Mantenido por retrocompatibilidad si se accede directo, aunque BatchProcessor usa su propio CSVManager) + try: + import threading + self._csv_lock = threading.Lock() + except Exception: + self._csv_lock = None + + @log_execution_time(logger=logger) + def extract_full_from_pdf( + self, + pdf_path: str, + instruction: Optional[str] = None, + *, + tiff_page_range: Optional[Tuple[int, int]] = None, + history: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """Extrae un JSON completo desde un documento (PDF/TIFF) usando el extractor. + + Estrategia: intenta con configuración por defecto. Si el backend responde + con un error (p. ej., 500 por payload demasiado grande), reintenta con + menor DPI y recortando a las primeras 5 páginas. + """ + filename = os.path.basename(pdf_path) + logger.info(f"Iniciando extracción de documento: {filename}", extra={"pdf": pdf_path}) + + t0 = time.time() + # Preparar imágenes según configuración + dpi_cfg = settings.imaging.pdf_dpi + thr_cfg = settings.imaging.pdf_thread_count + images_t0 = time.time() + tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3)) + + try: + images_b64 = document_to_images_bs64( + pdf_path, + dpi=dpi_cfg, + thread_count=thr_cfg, + tiff_pages=tiff_pages, + tiff_page_range=tiff_page_range, + ) + except Exception as e: + logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)}) + raise + + images_ms = int((time.time() - images_t0) * 1000) + logger.debug(f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg}, ms={images_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + + # Selección de instrucción por defecto según el contexto + profile = get_profile('invoice') + text_instruction = instruction if instruction is not None else profile["default_instruction"] + # Métricas del prompt (sin registrar el contenido completo por privacidad) + inst_preview = (text_instruction[:180] + "…") if isinstance(text_instruction, str) and len(text_instruction) > 180 else text_instruction + inst_len = len(text_instruction) if isinstance(text_instruction, str) else 0 + hist_len = len(history) if history else 0 + + logger.debug(f"Enviando a extractor {filename} (images={len(images_b64)}, prompt_len={inst_len}, history_len={hist_len})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + + try: + call_t0 = time.time() + model_output = self.extractor.extract(images_b64, text_instruction, history=history) + call_ms = int((time.time() - call_t0) * 1000) + logger.debug(f"Extractor OK para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + except Exception as e: + logger.warning(f"Fallo en extracción por defecto para {filename}. Reintentando con DPI reducido.", extra={"pdf": pdf_path, "error": str(e)}) + # Fallback: DPI reducido y limitar páginas + fb_dpi = 400 + tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3)) + # En fallback NO aplicamos `tiff_page_range`; se usa rango propio (pages<=5). + images_b64_fb = document_to_images_bs64(pdf_path, dpi=fb_dpi, tiff_pages=tiff_pages_fb) + # Limitar a 5 imágenes en fallback para controlar tamaño + if len(images_b64_fb) > 5: + images_b64_fb = images_b64_fb[:5] + logger.debug(f"Fallback activo para {filename} (images={len(images_b64_fb)}, dpi={fb_dpi})", extra={"pdf": pdf_path, "dpi": fb_dpi}) + call_t0 = time.time() + model_output = self.extractor.extract(images_b64_fb, text_instruction, history=history) + call_ms = int((time.time() - call_t0) * 1000) + logger.debug(f"Extractor OK tras fallback para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": fb_dpi}) + + raw_text = model_output.get("raw", "") + usage = model_output.get("usage") + json_text = extract_json_from_text(raw_text) + if not json_text: + logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path}) + raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.") + + data = json.loads(json_text) + # Resumen de claves extraídas (sin volcar todo el JSON) + try: + if isinstance(data, dict): + top_keys = list(data.keys()) + preview_keys = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "") + logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview_keys})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + else: + logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + except Exception: + pass + + total_ms = int((time.time() - t0) * 1000) + logger.info(f"Extracción finalizada exitosamente: {filename} (total_ms={total_ms})", extra={"pdf": pdf_path}) + return data, usage + + @log_execution_time(logger=logger) + async def extract_full_from_pdf_async( + self, + pdf_path: str, + instruction: Optional[str] = None, + *, + tiff_page_range: Optional[Tuple[int, int]] = None, + history: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """Versión asíncrona de extract_full_from_pdf.""" + filename = os.path.basename(pdf_path) + logger.info(f"Iniciando extracción asíncrona de documento: {filename}", extra={"pdf": pdf_path}) + + t0 = time.time() + # Preparar imágenes según configuración + dpi_cfg = settings.imaging.pdf_dpi + thr_cfg = settings.imaging.pdf_thread_count + images_t0 = time.time() + tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3)) + + try: + # Ejecutar conversión de imágenes en thread pool + images_b64 = await asyncio.to_thread( + document_to_images_bs64, + pdf_path, + dpi=dpi_cfg, + thread_count=thr_cfg, + tiff_pages=tiff_pages, + tiff_page_range=tiff_page_range, + ) + except Exception as e: + logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)}) + raise + + images_ms = int((time.time() - images_t0) * 1000) + logger.debug(f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg}, ms={images_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + + # Selección de instrucción por defecto según el contexto + profile = get_profile('invoice') + text_instruction = instruction if instruction is not None else profile["default_instruction"] + inst_len = len(text_instruction) if isinstance(text_instruction, str) else 0 + hist_len = len(history) if history else 0 + + logger.debug(f"Enviando a extractor {filename} (images={len(images_b64)}, prompt_len={inst_len}, history_len={hist_len})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + + try: + call_t0 = time.time() + model_output = await self.extractor.extract_async(images_b64, text_instruction, history=history) + call_ms = int((time.time() - call_t0) * 1000) + logger.debug(f"Extractor OK para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + except Exception as e: + logger.warning(f"Fallo en extracción por defecto para {filename}. Reintentando con DPI reducido.", extra={"pdf": pdf_path, "error": str(e)}) + # Fallback: DPI reducido y limitar páginas + fb_dpi = 400 + tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3)) + + images_b64_fb = await asyncio.to_thread( + document_to_images_bs64, + pdf_path, + dpi=fb_dpi, + tiff_pages=tiff_pages_fb + ) + + # Limitar a 5 imágenes en fallback para controlar tamaño + if len(images_b64_fb) > 5: + images_b64_fb = images_b64_fb[:5] + logger.debug(f"Fallback activo para {filename} (images={len(images_b64_fb)}, dpi={fb_dpi})", extra={"pdf": pdf_path, "dpi": fb_dpi}) + call_t0 = time.time() + model_output = await self.extractor.extract_async(images_b64_fb, text_instruction, history=history) + call_ms = int((time.time() - call_t0) * 1000) + logger.debug(f"Extractor OK tras fallback para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": fb_dpi}) + + raw_text = model_output.get("raw", "") + usage = model_output.get("usage") + json_text = extract_json_from_text(raw_text) + if not json_text: + logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path}) + raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.") + + data = json.loads(json_text) + # Resumen de claves extraídas (sin volcar todo el JSON) + try: + if isinstance(data, dict): + top_keys = list(data.keys()) + preview_keys = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "") + logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview_keys})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + else: + logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg}) + except Exception: + pass + + total_ms = int((time.time() - t0) * 1000) + logger.info(f"Extracción finalizada exitosamente: {filename} (total_ms={total_ms})", extra={"pdf": pdf_path}) + return data, usage + + def build_schema_from_first( + self, + pdf_path: str, + instruction: Optional[str] = None, + *, + tiff_page_range: Optional[Tuple[int, int]] = None, + ) -> Tuple[Dict[str, str], Dict[str, Any]]: + """Devuelve (schema, data) generados a partir del primer documento.""" + data, _usage = self.extract_full_from_pdf(pdf_path, instruction=instruction, tiff_page_range=tiff_page_range) + schema = build_schema(data) + try: + logger.debug(f"Schema generado para {os.path.basename(pdf_path)} (total_paths={len(schema)})", extra={"pdf": pdf_path}) + except Exception: + pass + return schema, data + + def build_schema_from_first_for_types( + self, + root_path: str, + doc_types: List[Dict[str, str]], + instruction: Optional[str] = None, + *, + tiff_page_range: Optional[Tuple[int, int]] = None, + structure_mode: str = "radicados", + ) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, Any]], str | None]: + """Genera schema y muestra para cada Tipo de Documento buscando el primer archivo disponible.""" + if structure_mode == "general": + radicados = get_files_as_radicados(root_path) + else: + radicados = get_sorted_radicados(root_path) + + if not radicados: + raise ValueError(f"No se encontraron documentos/radicados en: {root_path}") + + schema_by_type: Dict[str, Dict[str, str]] = {} + first_full_by_type: Dict[str, Dict[str, Any]] = {} + prof = get_profile('invoice') + inst0 = instruction if instruction is not None else prof["default_instruction"] + + first_radicado_found = None + + for dt in (doc_types or []): + tname = str(dt.get("name") or "").strip() or "Tipo" + kw = str(dt.get("keyword") or "").strip() + + pdf_path, rad_used = find_file_for_type_in_radicados(root_path, radicados, kw) + + if not pdf_path: + logger.info(f"No se encontró archivo para tipo '{tname}' con keyword '{kw}' en ningún radicado") + schema_by_type[tname] = {} + first_full_by_type[tname] = {} + continue + + if first_radicado_found is None: + first_radicado_found = rad_used + + try: + logger.info( + f"Usando archivo para schema por tipo '{tname}': {os.path.basename(pdf_path)} (Radicado: {rad_used})", + extra={"tipo": tname, "radicado": rad_used, "pdf": pdf_path}, + ) + sch, dat = self.build_schema_from_first(pdf_path, instruction=inst0, tiff_page_range=tiff_page_range) + schema_by_type[tname] = sch or {} + first_full_by_type[tname] = dat or {} + except Exception as e: + logger.exception(f"Error generando schema para tipo '{tname}'", extra={"tipo": tname, "radicado": rad_used}) + schema_by_type[tname] = {} + first_full_by_type[tname] = {"_error": str(e)} + + return schema_by_type, first_full_by_type, (first_radicado_found or radicados[0]) + + # --- Delegación a BatchProcessor y Estimator --- + + @log_execution_time(logger=logger) + def process_root_with_field_selection( + self, + root_path: str, + keywords: List[str] | None = None, + *, + doc_types: Optional[List[Dict[str, str]]] = None, + fields: Optional[List[str]] = None, + on_progress: Optional[callable] = None, + csv_output_path: Optional[str] = None, + csv_header_override: Optional[List[str]] = None, + instruction_override: Optional[str] = None, + on_usage: Optional[Callable[[str, str, Dict[str, Any] | None, Dict[str, Any] | None], None]] = None, + on_error_retry: Optional[Callable[[Exception], Awaitable[bool]]] = None, + alias_map: Optional[Dict[str, str]] = None, + tiff_page_range: Optional[Tuple[int, int]] = None, + structure_mode: str = "radicados", + ) -> List[Dict[str, Any]]: + """Procesa radicados con selección de campos y CSV incremental. (Delegado a BatchProcessor)""" + return self.batch_processor.process_root( + root_path, keywords, + doc_types=doc_types, fields=fields, on_progress=on_progress, + csv_output_path=csv_output_path, csv_header_override=csv_header_override, + instruction_override=instruction_override, on_usage=on_usage, + on_error_retry=on_error_retry, + alias_map=alias_map, tiff_page_range=tiff_page_range, + structure_mode=structure_mode + ) + + def estimate_tokens_and_cost( + self, + root_path: str, + keywords: List[str] | None = None, + schema: Dict[str, str] | None = None, + selected_fields: List[str] | None = None, + *, + doc_types: Optional[List[Dict[str, str]]] = None, + schema_by_type: Optional[Dict[str, Dict[str, str]]] = None, + selected_paths: Optional[List[str]] = None, + structure_mode: str = "radicados", + ) -> Dict[str, Any]: + """Calcula estimado de tokens y costos. (Delegado a Estimator)""" + return self.estimator.estimate( + root_path, keywords, schema, selected_fields, + doc_types=doc_types, schema_by_type=schema_by_type, selected_paths=selected_paths, + structure_mode=structure_mode + ) + + # CSV legacy helper (por si algún test lo llama directamente) + def _append_rows_to_csv(self, csv_path: str, rows: List[Dict[str, Any]], header: List[str]) -> None: + """DEPRECATED: Use CSVManager directly or via BatchProcessor.""" + # Delegar temporalmente a un CSVManager local para mantener compatibilidad sin duplicar código + # o simplemente usar la instancia del batch processor + self.batch_processor.csv_manager.append_rows(csv_path, rows, header) diff --git a/application/strategies.py b/application/strategies.py new file mode 100644 index 0000000..0b1e8d9 --- /dev/null +++ b/application/strategies.py @@ -0,0 +1,212 @@ +"""Estrategias de extracción de información desde imágenes de documentos. + +Define la interfaz `InvoiceExtractorStrategy` y una implementación basada en +un endpoint compatible con OpenAI (por ejemplo, Ollama) para modelos +multimodales como `qwen2.5vl`. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List, Dict, Any, Optional + +from .config import settings +from .llm_client import LLMClient +import logging +import time + + +class InvoiceExtractorStrategy(ABC): + """Interfaz para extraer datos a partir de páginas en base64 e instrucción.""" + @abstractmethod + def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + """Devuelve un dict con al menos la clave "raw" (texto de salida del modelo).""" + raise NotImplementedError + + async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + """Versión asíncrona de extract. Por defecto lanza NotImplementedError.""" + raise NotImplementedError + + +class OllamaExtractor(InvoiceExtractorStrategy): + """Extractor contra un servidor Ollama/OpenAI-compatible. + + Parámetros configurables: `api_key`, `base_url` y `model`. + """ + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, model: Optional[str] = None, llm_client: Optional[LLMClient] = None) -> None: + # Defaults desde settings si no se proveen + self.api_key = api_key or settings.api.api_key + self.base_url = base_url or settings.api.base_url + self.model = model or settings.api.model + self.llm = llm_client or LLMClient(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=settings.api.request_timeout_s) + self._logger = logging.getLogger(__name__) + + def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + """Construye el prompt multimodal y devuelve el texto crudo del modelo.""" + content: List[Dict[str, Any]] = [] + for image_b64 in images_b64: + content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{image_b64}"}, + }) + content.append({"type": "text", "text": instruction}) + + messages = [] + if history: + messages.extend(history) + messages.append({"role": "user", "content": content}) + + start = time.time() + try: + # Trazas DEBUG del request (sin exponer imágenes ni texto completo) + prompt_len = len(instruction) if isinstance(instruction, str) else 0 + # Log INFO con metadatos efectivos de la llamada + self._logger.info( + "backend_call_start", + extra={ + "base_url": self.base_url, + "model": self.model, + "timeout_s": settings.api.request_timeout_s, + "images": len(images_b64), + "prompt_len": prompt_len, + "history_len": len(history) if history else 0, + "dpi": '-', "pdf": '-', "radicado": '-', + }, + ) + self._logger.debug( + f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}", + extra={"dpi": '-', "pdf": '-', "radicado": '-'}, + ) + text, usage_dict = self.llm.chat_text( + messages=messages, + model=self.model, + timeout_s=settings.api.request_timeout_s, + component="extractor", + ) + elapsed = time.time() - start + self._logger.info( + "Extracción completada", + extra={ + "dpi": '-', "pdf": '-', "radicado": '-', + "base_url": self.base_url, "model": self.model, + "timeout_s": settings.api.request_timeout_s, + "elapsed_ms": int(elapsed * 1000), + }, + ) + self._logger.debug( + f"Backend OK (elapsed_ms={int(elapsed*1000)})", + extra={"dpi": '-', "pdf": '-', "radicado": '-'}, + ) + return { + "raw": text, + "usage": usage_dict, + } + except Exception: + elapsed = time.time() - start + self._logger.exception( + "Error en llamada al backend", + extra={ + "dpi": '-', "pdf": '-', "radicado": '-', + "base_url": self.base_url, "model": self.model, + "timeout_s": settings.api.request_timeout_s, + "elapsed_ms": int(elapsed * 1000), + }, + ) + try: + self._logger.debug( + f"Backend error tras {int(elapsed*1000)} ms", + extra={"dpi": '-', "pdf": '-', "radicado": '-'}, + ) + except Exception: + pass + raise + + async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + """Construye el prompt multimodal y devuelve el texto crudo del modelo de forma asíncrona.""" + content: List[Dict[str, Any]] = [] + for image_b64 in images_b64: + content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{image_b64}"}, + }) + content.append({"type": "text", "text": instruction}) + + messages = [] + if history: + messages.extend(history) + messages.append({"role": "user", "content": content}) + + start = time.time() + try: + # Trazas DEBUG del request (sin exponer imágenes ni texto completo) + prompt_len = len(instruction) if isinstance(instruction, str) else 0 + # Log INFO con metadatos efectivos de la llamada + self._logger.info( + "backend_call_start", + extra={ + "base_url": self.base_url, + "model": self.model, + "timeout_s": settings.api.request_timeout_s, + "images": len(images_b64), + "prompt_len": prompt_len, + "history_len": len(history) if history else 0, + "dpi": '-', "pdf": '-', "radicado": '-', + }, + ) + self._logger.debug( + f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}", + extra={"dpi": '-', "pdf": '-', "radicado": '-'}, + ) + text, usage_dict = await self.llm.chat_text_async( + messages=messages, + model=self.model, + timeout_s=settings.api.request_timeout_s, + component="extractor", + ) + elapsed = time.time() - start + self._logger.info( + "Extracción completada", + extra={ + "dpi": '-', "pdf": '-', "radicado": '-', + "base_url": self.base_url, "model": self.model, + "timeout_s": settings.api.request_timeout_s, + "elapsed_ms": int(elapsed * 1000), + }, + ) + self._logger.debug( + f"Backend OK (elapsed_ms={int(elapsed*1000)})", + extra={"dpi": '-', "pdf": '-', "radicado": '-'}, + ) + return { + "raw": text, + "usage": usage_dict, + } + except Exception: + elapsed = time.time() - start + self._logger.exception( + "Error en llamada al backend", + extra={ + "dpi": '-', "pdf": '-', "radicado": '-', + "base_url": self.base_url, "model": self.model, + "timeout_s": settings.api.request_timeout_s, + "elapsed_ms": int(elapsed * 1000), + }, + ) + try: + self._logger.debug( + f"Backend error tras {int(elapsed*1000)} ms", + extra={"dpi": '-', "pdf": '-', "radicado": '-'}, + ) + except Exception: + pass + raise + + def update_config(self, *, api_key: str | None = None, base_url: str | None = None, model: str | None = None) -> None: + """Actualiza la configuración del cliente y recrea la instancia interna.""" + if api_key is not None: + self.api_key = api_key + if base_url is not None: + self.base_url = base_url + if model is not None: + self.model = model + # actualizar cliente LLM con config actual + self.llm.update_config(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=settings.api.request_timeout_s) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..9dfaa85 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,3 @@ +# core package + + diff --git a/core/csv_utils.py b/core/csv_utils.py new file mode 100644 index 0000000..6c048b4 --- /dev/null +++ b/core/csv_utils.py @@ -0,0 +1,171 @@ +"""Utilidades para preparar filas y encabezados CSV a partir de JSON extraído. + +Extrae y aplana campos no-lista, detecta primera lista de objetos y genera +filas combinando campos repetidos con cada item. Permite: +- filtrar por `allowed_paths` +- agregar columnas agregadas para listas específicas (path base) +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + + +def flatten_non_list_fields(obj: Any, prefix: str = "") -> Dict[str, Any]: + flat: Dict[str, Any] = {} + if isinstance(obj, dict): + for k, v in obj.items(): + path = f"{prefix}/{k}" if prefix else k + if isinstance(v, dict): + flat.update(flatten_non_list_fields(v, path)) + elif isinstance(v, list): + # omit lists here; handled separately as items + continue + else: + flat[path] = v + else: + flat[prefix or "value"] = obj + return flat + + +def _flatten_item_dict(it: Dict[str, Any], prefix: str = "") -> Dict[str, Any]: + """Aplana recursivamente un dict de ítem, usando '/' como separador. + + Ejemplo: + {"V": {"Unitario": 10}, "Codigo": "X"} -> {"V/Unitario": 10, "Codigo": "X"} + """ + flat: Dict[str, Any] = {} + for k, v in it.items(): + base = f"{prefix}{k}" if not prefix else f"{prefix}{k}" + if isinstance(v, dict): + flat.update(_flatten_item_dict(v, base + "/")) + else: + flat[base] = v + return flat + + +def _find_first_items_list(obj: Any, exclude_paths: Optional[set[str]] = None, prefix: str = "") -> Tuple[str, List[Dict[str, Any]] | None]: + exclude_paths = exclude_paths or set() + if isinstance(obj, dict): + for k, v in obj.items(): + path = f"{prefix}/{k}" if prefix else k + if isinstance(v, list) and v and isinstance(v[0], dict): + if path not in exclude_paths: + return path, v + if isinstance(v, dict): + sub_key, sub_list = _find_first_items_list(v, exclude_paths, path) + if sub_list is not None: + return sub_key, sub_list + return "", None + + +def _get_at_path(obj: Any, path: str) -> Any: + parts = [p for p in path.split('/') if p] + cur = obj + for p in parts: + if isinstance(cur, dict) and p in cur: + cur = cur[p] + else: + return None + return cur + + +def aggregate_list_fields(data: Dict[str, Any], aggregate_paths: List[str]) -> Dict[str, Any]: + aggregated: Dict[str, Any] = {} + for base_path in aggregate_paths: + lst = _get_at_path(data, base_path) + if not (isinstance(lst, list) and lst and isinstance(lst[0], dict)): + continue + subkeys = set() + for it in lst: + subkeys.update([k for k in it.keys()]) + for sk in subkeys: + values = [] + for it in lst: + val = it.get(sk) + if val is None: + continue + values.append(str(val)) + key = f"{base_path}/*/{sk}" + aggregated[key] = ", ".join(values) + return aggregated + + +def rows_from_data( + data: Dict[str, Any], + aggregate_list_paths: Optional[List[str]] = None, + allowed_paths: Optional[List[str]] = None, +) -> Tuple[List[Dict[str, Any]], List[str]]: + """Construye filas y encabezado para CSV desde un JSON estructurado. + + - aggregate_list_paths: rutas base de listas a agregar como columnas unidas por coma. + - allowed_paths: si se provee, limita las columnas a estas rutas (siempre incluye 'radicado'). + """ + non_list_full = flatten_non_list_fields(data) + agg_paths = aggregate_list_paths or [] + non_list = dict(non_list_full) + if agg_paths: + aggregated = aggregate_list_fields(data, agg_paths) + if allowed_paths is not None: + aggregated = {k: v for k, v in aggregated.items() if k in set(allowed_paths)} + non_list.update(aggregated) + + if allowed_paths is not None: + allowed_set = set(allowed_paths) + kept = {k: v for k, v in non_list.items() if (k in allowed_set or k == 'radicado')} + non_list = kept + + exclude = set(agg_paths) if agg_paths else set() + items_key, items = _find_first_items_list(data, exclude_paths=exclude) + if not items: + header_nonlist: List[str] = [] + if 'radicado' in non_list: + header_nonlist.append('radicado') + header_nonlist.extend([k for k in non_list.keys() if k != 'radicado']) + return [non_list], header_nonlist + + rows: List[Dict[str, Any]] = [] + header_nonlist: List[str] = [] + if 'radicado' in non_list: + header_nonlist.append('radicado') + header_nonlist.extend([k for k in non_list.keys() if k != 'radicado']) + + item_keys = set() + # allowed subrutas dentro de items, p. ej. "V/Unitario" + allowed_item_subpaths: Optional[set[str]] = None + if allowed_paths is not None: + allowed_item_subpaths = set() + prefix = f"{items_key}/*/" + for p in allowed_paths: + if p.startswith(prefix): + allowed_item_subpaths.add(p[len(prefix):]) + + for it in items: + flat_it = _flatten_item_dict(it) + for subpath in flat_it.keys(): + if allowed_item_subpaths is not None and subpath not in allowed_item_subpaths: + continue + if '/' in items_key: + item_keys.add(f"{items_key}/*/{subpath}") + else: + item_keys.add(subpath) + + header = header_nonlist + sorted(item_keys) + for it in items: + row = dict(non_list) + flat_it = _flatten_item_dict(it) + if '/' in items_key: + for subpath, val in flat_it.items(): + if allowed_item_subpaths is not None and subpath not in allowed_item_subpaths: + continue + row[f"{items_key}/*/{subpath}"] = val + else: + if allowed_item_subpaths is None: + row.update(flat_it) + else: + for subpath, val in flat_it.items(): + if subpath in allowed_item_subpaths: + row[subpath] = val + rows.append(row) + return rows, header + + diff --git a/core/pdf_utils.py b/core/pdf_utils.py new file mode 100644 index 0000000..31262ce --- /dev/null +++ b/core/pdf_utils.py @@ -0,0 +1,180 @@ +"""Utilidades para convertir documentos (PDF/TIFF) a imágenes base64. + +En Windows se requiere Poppler. Este proyecto incluye una ruta por defecto: +`dependencies/poppler-24.08.0/Library/bin`. Ajuste si su instalación difiere. +""" +import base64 +import logging +import os +from io import BytesIO +from typing import List, Tuple, Optional +import time + +from pdf2image import convert_from_path, pdfinfo_from_path +from PIL import Image, ImageSequence + +Image.MAX_IMAGE_PIXELS = None + + +from application.config import settings + + +logger = logging.getLogger(__name__) + + +def pdf_to_images_bs64(pdf_path: str, validate: bool = True, dpi: int = 800, thread_count: int = 10) -> List[str]: + """Convierte un PDF a una lista de PNGs en base64. + + Usa pdf2image + Poppler. Lanza ValueError si una imagen no valida. + """ + poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin' + t0 = time.time() + logger.info("Convirtiendo PDF a imágenes", extra={"pdf": pdf_path, "dpi": dpi}) + images = convert_from_path(pdf_path, dpi=dpi, thread_count=thread_count, poppler_path=poppler_bin) + resultados: List[str] = [] + for indice, imagen in enumerate(images): + buffer = BytesIO() + imagen.save(buffer, format="PNG") + b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + if validate: + try: + raw = base64.b64decode(b64, validate=True) + with Image.open(BytesIO(raw)) as im: + im.verify() + except Exception as error: + raise ValueError(f"Imagen base64 inválida en la página {indice + 1}: {error}") + resultados.append(b64) + elapsed_ms = int((time.time() - t0) * 1000) + logger.debug(f"PDF->imgs OK (pages={len(resultados)}, ms={elapsed_ms}, threads={thread_count})", extra={"pdf": pdf_path, "dpi": dpi}) + logger.info("PDF convertido", extra={"pdf": pdf_path, "dpi": dpi}) + return resultados + + +def get_pdf_num_pages(pdf_path: str) -> int: + """Devuelve el número de páginas de un PDF usando pdfinfo_from_path.""" + poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin' + info = pdfinfo_from_path(pdf_path, poppler_path=poppler_bin) + try: + return int(info.get('Pages') or info.get('pages') or 0) + except Exception: + return 0 + + +def pdf_pages_to_images_bs64( + pdf_path: str, + first_page: int, + last_page: int, + *, + dpi: int, + grayscale: bool = True, + validate: bool = False, + thread_count: int = 10, +) -> List[str]: + """Convierte un rango [first_page, last_page] (1-indexed, inclusivo) a PNGs base64. + + Opcionalmente aplica escala de grises y omite validación para acelerar. + """ + poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin' + t0 = time.time() + logger.debug( + f"Convirtiendo rango PDF→imgs (first={first_page}, last={last_page}, dpi={dpi}, gray={grayscale})", + extra={"pdf": pdf_path, "dpi": dpi}, + ) + images = convert_from_path( + pdf_path, + dpi=dpi, + thread_count=thread_count, + poppler_path=poppler_bin, + first_page=first_page, + last_page=last_page, + ) + resultados: List[str] = [] + for idx, img in enumerate(images, start=first_page): + if grayscale: + img = img.convert('L') + buffer = BytesIO() + img.save(buffer, format="PNG") + b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + if validate: + try: + raw = base64.b64decode(b64, validate=True) + with Image.open(BytesIO(raw)) as im: + im.verify() + except Exception as error: + raise ValueError(f"Imagen base64 inválida en la página {idx}: {error}") + resultados.append(b64) + logger.debug( + f"Rango convertido (count={len(resultados)}, ms={int((time.time()-t0)*1000)})", + extra={"pdf": pdf_path, "dpi": dpi}, + ) + return resultados + +def tiff_to_images_bs64(tiff_path: str, validate: bool = True, pages_range: Optional[Tuple[int, int]] = (2, 3)) -> List[str]: + """Convierte un TIFF multipágina a PNGs en base64. + + Por defecto, procesa un rango de páginas inclusivo cuando se especifica + `pages_range` como `(inicio, fin)`, usando índices 1-based. Use None para + procesar todas las páginas. Puede pasar `None` en uno de los extremos para + indicar “sin inicio” o “sin fin”. + """ + resultados: List[str] = [] + t0 = time.time() + logger.info("Convirtiendo TIFF a imágenes", extra={"pdf": tiff_path}) + with Image.open(tiff_path) as im: + frames = [] + for idx, frame in enumerate(ImageSequence.Iterator(im), start=1): + if pages_range is not None: + start, end = pages_range + # Permitir extremos None para significar “sin límite” por ese lado + if start is not None and idx < start: + continue + if end is not None and idx > end: + continue + frames.append(frame.copy()) + for indice, frame in enumerate(frames): + buffer = BytesIO() + frame.convert("RGB").save(buffer, format="PNG") + b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + if validate: + try: + raw = base64.b64decode(b64, validate=True) + with Image.open(BytesIO(raw)) as imv: + imv.verify() + except Exception as error: + raise ValueError(f"Imagen base64 inválida en la página TIFF {indice + 1}: {error}") + resultados.append(b64) + elapsed_ms = int((time.time() - t0) * 1000) + logger.debug(f"TIFF->imgs OK (pages={len(resultados)}, ms={elapsed_ms})", extra={"pdf": tiff_path}) + logger.info("TIFF convertido", extra={"pdf": tiff_path}) + return resultados + +def document_to_images_bs64( + path: str, + validate: bool = True, + dpi: int = 600, + thread_count: int = 10, + tiff_pages: Optional[Tuple[int, int]] = (2, 3), + tiff_page_range: Optional[Tuple[int, int]] = None, +) -> List[str]: + """Puerta de entrada: decide conversión según la extensión (.pdf, .tif/.tiff). + + `tiff_page_range` permite indicar un rango explícito (inicio, fin) 1-based + para TIFF. Si es None, se usa `tiff_pages` como valor por defecto. + """ + ext = os.path.splitext(path)[1].lower() + if ext == ".pdf": + return pdf_to_images_bs64(path, validate=validate, dpi=dpi, thread_count=thread_count) + if ext in (".tif", ".tiff"): + # Prioridad: + # - (None, None) => procesar todas las páginas del TIFF (sin recorte). + # - rango explícito (inicio, fin) => usar ese rango. + # - None => usar el valor por defecto de configuración (`tiff_pages`). + effective_range = tiff_pages + if tiff_page_range is not None: + if tiff_page_range == (None, None): + effective_range = None + else: + effective_range = tiff_page_range + return tiff_to_images_bs64(path, validate=validate, pages_range=effective_range) + raise ValueError(f"Formato no soportado: {ext}") + diff --git a/core/text_utils.py b/core/text_utils.py new file mode 100644 index 0000000..8e4ffab --- /dev/null +++ b/core/text_utils.py @@ -0,0 +1,56 @@ +"""Utilidades de texto para parseo de JSON embebido y construcción de schema.""" +import re +import json +from typing import Optional, Dict, Any + + +def extract_json_from_text(text: str) -> Optional[str]: + """Extrae el primer bloque JSON de un texto, con o sin fence ```json. + + Devuelve la cadena JSON o None si no se encuentra. + """ + # 1. Try markdown fences + match = re.search(r"```json\s*(.*?)\s*```", text, flags=re.DOTALL | re.IGNORECASE) + if match: + return _clean_json_string(match.group(1).strip()) + + match = re.search(r"```\s*(\{[\s\S]*?\})\s*```", text, flags=re.DOTALL) + if match: + return _clean_json_string(match.group(1).strip()) + + # 2. Try finding outer braces + start = text.find('{') + end = text.rfind('}') + if start != -1 and end != -1 and end > start: + candidate = text[start:end + 1].strip() + return _clean_json_string(candidate) + + return None + + +def _clean_json_string(json_str: str) -> str: + """Intenta limpiar errores comunes de JSON generado por LLM.""" + # Eliminar comas finales en objetos/listas: , } -> } y , ] -> ] + # Nota: Esto es una heurística simple y puede fallar en strings que contienen esos patrones. + # Usamos regex conservador. + json_str = re.sub(r",\s*}", "}", json_str) + json_str = re.sub(r",\s*]", "]", json_str) + return json_str + + +def build_schema(data: Dict[str, Any], prefix: str = "") -> Dict[str, str]: + """Construye un mapa ruta->tipo a partir de un dict anidado. + + Para listas de dicts, usa la notación "/*/subcampo". + """ + schema: Dict[str, str] = {} + for k, v in data.items(): + path = f"{prefix}/{k}" if prefix else k + if isinstance(v, dict): + schema.update(build_schema(v, path)) + elif isinstance(v, list) and v and isinstance(v[0], dict): + for subkey, subval in v[0].items(): + schema[f"{path}/*/{subkey}"] = type(subval).__name__ + else: + schema[path] = type(v).__name__ + return schema diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..4096c32 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,152 @@ +"""Utilidades generales: parseo, descubrimiento de documentos y recolección. + +Incluye heurísticas para elegir el documento a procesar por radicado, +priorizando TIFF cuando existe y recurriendo a PDF/TIFF por keywords o patrones. +""" +import os +import fnmatch +from typing import List, Dict + + +# Extensiones de documentos soportados para búsqueda por patrones +ALLOWED_DOC_EXTS = (".pdf", ".tif", ".tiff") + + +def parse_comma_separated(value: str) -> List[str]: + """Convierte una cadena coma-separada en lista normalizada (sin vacíos).""" + return [v.strip() for v in value.split(',') if v and v.strip()] + + +def _matches_pattern(filename: str, pattern: str) -> bool: + """Determina si `filename` coincide con `pattern` usando semántica tipo Windows. + + - Admite comodines `*` y `?` en el patrón. + - Si el patrón incluye una extensión válida (.pdf/.tif/.tiff), se compara contra + el nombre completo (incluida la extensión). + - Si el patrón no incluye una extensión válida, se compara solo contra el nombre + sin extensión, pero siempre restringiendo a archivos con extensiones permitidas. + - Si el patrón no tiene comodines, se interpreta como 'contiene' (case-insensitive) + sobre el nombre completo, respetando igualmente las extensiones permitidas. + """ + if not isinstance(filename, str) or not isinstance(pattern, str): + return False + fname = filename.lower() + pat = pattern.lower().strip() + if not pat: + return False + + # Solo considerar archivos con extensión permitida + stem, ext = os.path.splitext(fname) + if ext not in ALLOWED_DOC_EXTS: + return False + + has_wildcards = "*" in pat or "?" in pat + + if has_wildcards: + p_stem, p_ext = os.path.splitext(pat) + # Patrón con extensión explícita soportada: aplicar sobre nombre completo + if p_ext in ALLOWED_DOC_EXTS: + return fnmatch.fnmatch(fname, pat) + # Patrón sin extensión explícita: aplicar sobre el nombre sin extensión + return fnmatch.fnmatch(stem, pat) + + # Sin comodines: interpretar como 'contiene' en el nombre completo + return pat in fname + + +def find_pdf_in_dir_by_keywords(dir_path: str, keywords: List[str]) -> str | None: + """Devuelve la primera ruta (PDF/TIFF) cuyo nombre coincide con alguno de los patrones. + + - Acepta keywords simples (sin `*`/`?`) como 'contiene' insensible a mayúsculas. + - Acepta patrones con `*` y `?` tipo Windows (p. ej. `*_1`, `*_OK?.pdf`). Solo + se consideran archivos con extensiones en `ALLOWED_DOC_EXTS`. + """ + if not os.path.isdir(dir_path): + return None + files = [f for f in os.listdir(dir_path) if f.lower().endswith(ALLOWED_DOC_EXTS)] + if not files or not keywords: + return None + for kw in keywords: + matches = [f for f in files if _matches_pattern(f, kw)] + if matches: + matches.sort() + return os.path.join(dir_path, matches[0]) + return None + + +def find_first_tiff(dir_path: str) -> str | None: + """Devuelve la primera ruta a TIFF encontrada (orden lexicográfico).""" + if not os.path.isdir(dir_path): + return None + for f in sorted(os.listdir(dir_path)): + if f.lower().endswith(('.tif', '.tiff')): + return os.path.join(dir_path, f) + return None + + +def collect_radicado_docs(root_path: str, keywords: List[str]) -> List[Dict[str, str]]: + """Para cada radicado, prioriza TIFF; si no hay, busca PDF/TIFF por keywords/patrones.""" + entries: List[Dict[str, str]] = [] + if not os.path.isdir(root_path): + raise ValueError(f"La ruta raíz no es un directorio: {root_path}") + for radicado in sorted(os.listdir(root_path)): + subdir = os.path.join(root_path, radicado) + if not os.path.isdir(subdir): + continue + selected = find_first_tiff(subdir) + if not selected: + selected = find_pdf_in_dir_by_keywords(subdir, keywords) + if not selected: + continue + entries.append({ + "radicado": radicado, + "path": selected, + }) + return entries + + +def collect_documents_flat(root_path: str, keywords: List[str] = None) -> List[Dict[str, str]]: + """Recolecta documentos PDF/TIFF directamente en la raíz. + + El 'radicado' es el nombre del archivo sin extensión. + Si se proveen keywords, solo incluye archivos que coincidan. + """ + entries: List[Dict[str, str]] = [] + if not os.path.isdir(root_path): + raise ValueError(f"La ruta raíz no es un directorio: {root_path}") + + files = sorted([f for f in os.listdir(root_path) if f.lower().endswith(ALLOWED_DOC_EXTS)]) + + for f in files: + # Si hay keywords, filtrar + if keywords: + match = False + for kw in keywords: + if _matches_pattern(f, kw): + match = True + break + if not match: + continue + + radicado = os.path.splitext(f)[0] + entries.append({ + "radicado": radicado, + "path": os.path.join(root_path, f) + }) + return entries + + +def find_first_matching_file(files: list[str], keyword: str) -> str | None: + """Devuelve el primer nombre de archivo que coincide con el keyword/patrón. + + - Usa `_matches_pattern` para soportar comodines `*` y `?`. + - Solo considera archivos con extensiones en `ALLOWED_DOC_EXTS`. + - Retorna el nombre de archivo tal cual se recibió. + """ + if not files or not keyword: + return None + candidates = [f for f in files if _matches_pattern(f, keyword)] + if not candidates: + return None + candidates.sort() + return candidates[0] diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..faa4964 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,270 @@ +# IPSExtractor — Documentación técnica (Desarrolladores) + +Este documento explica la arquitectura, módulos, flujos, modelo de datos/CSV, estados de la GUI, configuración y extensibilidad del proyecto. Contiene diagramas Mermaid incrustados. + +## Índice + +- [Visión general](#visión-general) +- [Arquitectura de componentes](#arquitectura-de-componentes) +- [Módulos y responsabilidades](#módulos-y-responsabilidades) +- [Flujos de ejecución](#flujos-de-ejecución) +- [Modelo de datos y CSV](#modelo-de-datos-y-csv) +- [Estados de la GUI y manejo de errores](#estados-de-la-gui-y-manejo-de-errores) +- [Configuración y ejecución](#configuración-y-ejecución) +- [Extensibilidad](#extensibilidad) +- [Registro y métricas](#registro-y-métricas) + +## Visión general + +La aplicación procesa carpetas con “radicados” que contienen PDFs/TIFFs de facturas. Orquesta la conversión de documentos a imágenes Base64, llama a una estrategia de extracción para obtener un JSON, normaliza el resultado a un esquema seleccionado por el usuario y exporta CSV incremental. Incluye GUI con selección interactiva de campos y estimación de costos por tokens. + +## Arquitectura de componentes + +```mermaid +flowchart LR + subgraph GUI + V[gui/view.py] + C[gui/controller.py] + FS[gui/field_selector.py] + end + + subgraph APP[application/*] + S[service.py] + STG[strategies.py
(InvoiceExtractorStrategy)] + NRM[normalizer.py] + PR[profiles.py] + PB[prompts.py] + CFG[config.py] + end + + subgraph CORE + PDF[core/pdf_utils.py] + TXT[core/text_utils.py] + CSV[core/csv_utils.py] + UT[core/utils.py] + end + + V --> C + C --> S + C --> FS + S --> STG + S --> NRM + S --> PR + S --> PB + S --> CFG + S --> PDF + S --> TXT + S --> CSV + S --> UT + + S --> LOG[(logs/*)] +``` + +## Módulos y responsabilidades + +- `application/service.py`: orquestación principal (extracción con fallback, schema inicial, normalización, CSV incremental, estimación de costos/tokens). + - Funciones clave: + - `extract_full_from_pdf(pdf_path, instruction?)`: conversión a imágenes, llamada a extractor, parseo JSON, fallback DPI/páginas. + - `build_schema_from_first(pdf_path, instruction?)`: genera `schema` a partir del primer documento. + - `build_schema_from_first_for_types(root_path, doc_types)`: genera `schema_by_type` usando el primer radicado y la primera coincidencia por keyword de cada tipo. + - `process_root_with_field_selection(root_path, keywords|doc_types, fields?, ...)`: flujo por radicado; soporta multi-documento; selección de campos; normalización y CSV incremental. + - `estimate_tokens_and_cost(root_path, keywords|doc_types, ...)`: simulación con primer radicado; en multi suma por tipo. +- `application/profiles.py`: perfiles por tipo de documento e instrucciones por defecto. +- `application/strategies.py`: interfaz/estrategia de extracción (`InvoiceExtractorStrategy`). +- `application/normalizer.py`: `normalize_to_schema(...)` para ajustar el JSON al esquema elegido. +- `application/prompts.py`: `PromptBuilder.build(...)` para prompts reducidos según campos seleccionados. +- `application/config.py`: `settings` (imaging, hilos, DPI, etc.). +- `core/pdf_utils.py`: conversión PDF/TIFF → imágenes Base64. +- `core/text_utils.py`: `extract_json_from_text`, `build_schema`. +- `core/csv_utils.py`: aplanado de campos no-lista, detección de la primera lista de ítems, agregaciones y encabezados. +- `core/utils.py`: utilidades (p. ej., `collect_radicado_docs`). +- `gui/controller.py`: coordina GUI ↔ servicio, selector de campos y callbacks de progreso/uso. +- `gui/field_selector.py`: diálogo de selección de paths y alias (valida duplicados y permite reproceso). En modo multi, agrupa visualmente por `Tipo` (p. ej., `Factura/...`, `Remision/...`). +- `gui/view.py`: añade lista dinámica de Tipos de Documento (Nombre Lógico + Keyword). +- `gui/view.py`: componentes visuales y actualizaciones (KPIs, barras, líneas de costo, etc.). + +## Flujos de ejecución + +Incluye: disparo desde GUI, descubrimiento de radicados, creación de schema, selección de campos/alias, estimación y confirmación, extracción por documento con fallback, normalización y exportación incremental a CSV. + +### Secuencia general (GUI → Service → Normalización → CSV) + +```mermaid +sequenceDiagram + autonumber + actor U as Usuario + participant V as gui/view.py + participant C as gui/controller.py + participant S as application/service.py + participant FS as gui/field_selector.py + participant ST as application/strategies.py + participant N as application/normalizer.py + participant CSV as core/csv_utils.py + participant TXT as core/text_utils.py + participant UT as core/utils.py + + U->>V: Click "Procesar" + V->>C: on_process() + note over C: Lee root, keywords, fields opcionales + C->>S: process_root_with_field_selection(fields=None, doc_types?) + S->>UT: collect_radicado_docs() + S->>S: build_schema_from_first(first.pdf) + S->>TXT: extract_json_from_text(raw) + S-->>C: [{ _needs_fields: true, schema, first_full }] + C->>FS: Abrir diálogo de selección (schema + sample) + FS-->>C: on_submit(selected_paths, alias_map) Note: paths con prefijo de tipo `Tipo/Subruta` + C->>S: estimate_tokens_and_cost(..., doc_types?, schema_by_type?, selected_paths?) + C->>U: Confirmación (costos estimados) + U-->>C: Aceptar + C->>S: process_root_with_field_selection(fields=selected_fields, alias_map) + loop Por cada radicado + alt Multi-documento + loop Por cada Tipo + S->>ST: extract(images, prompt reducido por Tipo) + S->>N: normalize_to_schema(data, paths del Tipo) + end + S-->>S: fusiona por Tipo => fused_data = {'radicado': R, 'Factura': {...}, 'Remision': {...}} + S->>CSV: rows_from_data(fused_data, allowed_paths con prefijo) + else Single-documento + S->>ST: extract(images, prompt reducido) + S->>N: normalize_to_schema(data, paths) + S->>CSV: rows_from_data(data, allowed_paths, aggregate_list_paths?) + end + S->>S: _append_rows_to_csv(...) + S-->>C: on_usage(phase, radicado, usage, cost) + C->>V: Actualiza KPIs/barras/líneas + end + S-->>C: resultados finales (JSON) + C->>V: Imprime resultados en Output +``` + +### Secuencia de extracción con fallback + +```mermaid +sequenceDiagram + autonumber + participant S as application/service.py + participant PDF as core/pdf_utils.py + participant ST as application/strategies.py + participant TXT as core/text_utils.py + + S->>PDF: document_to_images_bs64(pdf, dpi=config, threads) + S->>ST: extract(images_b64, instruction) + alt Éxito + ST-->>S: {raw, usage} + else Error/Timeout/Payload + S->>PDF: document_to_images_bs64(pdf, dpi=fallback, pages<=5) + S->>ST: extract(images_b64_fb, instruction) + ST-->>S: {raw, usage} + end + S->>TXT: extract_json_from_text(raw) + TXT-->>S: json_text + S-->>S: json.loads(json_text) => data +``` + +## Modelo de datos y CSV + +Describe cómo se aplanan campos no-lista, cómo se detecta la primera lista de ítems, cómo se agregan listas a columnas y cómo `allowed_paths` y los alias determinan el encabezado y las filas del CSV. + +### Diagrama de datos + +```mermaid +classDiagram + class ExtractedData { + +string radicado + +dict ...campos_no_lista + +Item[] items + +string _source_pdf + } + class Item { + +string Codigo + +int Cantidad + +float V/Unitario + +... otros subcampos anidados + } + ExtractedData "1" --> "0..*" Item : items +``` + +Notas: +- Los “campos no lista” se aplanan con separador `/`, por ejemplo: `Paciente/Nombre`, `Factura/Fecha`. +- La primera lista de objetos detectada se considera “lista de ítems principal” y se expande en columnas. + +### Mapeo a CSV (`core/csv_utils.py`) + +- Aplanado no-lista: `flatten_non_list_fields(data)` produce columnas para todas las hojas escalar (excluye listas). +- Detección de lista principal: `_find_first_items_list(data)` devuelve `items_key` y `items`. +- Subcolumnas de ítems: se aplanan como `items_key/*/subruta`, por ejemplo: `items/*/V/Unitario`. +- Filtrado por `allowed_paths` (selección del usuario): solo se mantienen rutas de esa lista (más `radicado` siempre). +- Agregación opcional: `aggregate_list_paths=[base_path]` genera columnas `base_path/*/subclave` con valores unidos por coma (para listas no principales). +- Encabezado final: `['radicado', ...campos_no_lista, ...campos_items]` respetando `allowed_paths` y orden alfabético de subrutas. +- Alias: el `alias_map` de la GUI renombra columnas en el CSV final (encabezado y filas). + +Ejemplos de columnas resultantes: +- `radicado` +- `Factura/Número` +- `Paciente/Nombre` +- `items/*/Codigo` +- `items/*/V/Unitario` + +## Estados de la GUI y manejo de errores + +Estados principales: Idle → Processing → (NeedsFields) → Estimating → Confirming → Processing2 → Done/Error. La extracción tiene fallback controlado y los errores se registran y opcionalmente se reflejan como filas de error en el CSV. + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Processing: Click "Procesar" + Processing --> NeedsFields: Service devuelve _needs_fields + NeedsFields --> Estimating: on_submit(selected_fields, alias_map) + Estimating --> Confirming: Mostrar costos estimados + Confirming --> Processing2: Usuario acepta + Confirming --> Idle: Usuario cancela + Processing2 --> Done: Todos los radicados OK + Processing2 --> Error: Excepción por radicado + Done --> Idle + Error --> Idle +``` + +Manejo de errores/fallback: +- Extracción: si falla la primera llamada (timeout/payload), se reprocesa con DPI reducido y límite de páginas (≤5). +- Escritura CSV: filas de error con `radicado`, `_source_pdf`, `error` cuando hay excepción. +- Logs: se emiten `logger.info/debug/exception` con contexto (`pdf`, `radicado`, métricas de tiempo y tokens). +- GUI: mantiene KPIs y barreras por fase; errores no bloquean el procesamiento de los siguientes radicados. + +## Configuración y ejecución + +Parámetros clave en `application/config.py`: +- `settings.imaging.pdf_dpi`: DPI para rasterizar PDF/TIFF (impacta calidad/tamaño). +- `settings.imaging.pdf_thread_count`: paralelismo al generar imágenes. +- `settings.imaging.tiff_pages`: páginas a considerar por defecto en TIFF multi-página. + +Ejecución (GUI): +1) Inicie la aplicación GUI. +2) Seleccione la carpeta raíz de radicados (cada radicado contiene PDFs/TIFFs). +3) Opcional: defina palabras clave de búsqueda (por defecto: `Factura_O_Documento_Equivalente, factura`). +4) Opcional: defina campos a extraer (si se deja vacío se abrirá el selector). +5) Opcional: elija ruta de salida para el CSV. +6) Presione “Procesar”. + +Exportación CSV: +- Si se especifica `csv_output_path`, el servicio añadirá filas incrementalmente conforme procesa cada documento. +- Los encabezados se generan en la primera escritura; si existe un CSV previo con contenido, se preserva su encabezado. + +## Extensibilidad + +Nuevos perfiles, estrategias de extracción o ajustes en normalización y costos se pueden incorporar con cambios locales y mínimos acoplamientos. + +- Perfiles (`application/profiles.py`): agregue una entrada en `PROFILES` definiendo `default_instruction`, `aggregate_list_paths`, etc. +- Estrategias de extracción (`application/strategies.py`): implemente `InvoiceExtractorStrategy.extract(images_b64, instruction)` para soportar nuevos backends/LLMs. +- Normalización (`application/normalizer.py`): adapte `normalize_to_schema` para modelos y políticas de mapeo; los costos por fase se calculan en el servicio. +- Prompts (`application/prompts.py`): `PromptBuilder.build(selected_fields, schema)` puede ajustarse para controlar la precisión y tamaño del prompt. +- Estimación de costos (`application/service.py::estimate_tokens_and_cost`): modifique tarifas/modelos según su despliegue. + +Preferencia de diseño: +- Siempre que sea posible, se recomienda ajustar el prompt para conseguir un JSON estable en vez de depender de mapeos/alias complejos en código [[memory:10659104]]. + +## Registro y métricas + +La app registra progreso, tiempos y costos por fase y por documento. Los logs se escriben en `logs/*` y la GUI muestra KPIs agregados. + + diff --git a/docs/application/README.md b/docs/application/README.md new file mode 100644 index 0000000..0ba75d7 --- /dev/null +++ b/docs/application/README.md @@ -0,0 +1,15 @@ +# Documentación de `application/` + +## Índice +- [`service.py`](./service.md): fachada de procesamiento de facturas. +- [`normalizer.py`](./normalizer.md): normalización LLM a esquema canónico. +- [`strategies.py`](./strategies.md): estrategias de extracción (OpenAI/Ollama). +- [`profiles.py`](./profiles.md): perfiles e instrucciones por defecto. +- [`prompts.py`](./prompts.md): construcción de prompts. +- [`config.py` y `logging_config.py`](./config.md): configuración y logging (unificado). + +## Relaciones entre módulos +- `service` usa `strategies` para extraer, `profiles` para instrucciones por defecto, `prompts` para prompts reducidos y `normalizer` cuando se requiere adaptar al esquema canónico. +- `config` provee `settings` consumido por varios módulos; `logging_config` inicializa logging consistente. + + diff --git a/docs/application/config.md b/docs/application/config.md new file mode 100644 index 0000000..5ad33dc --- /dev/null +++ b/docs/application/config.md @@ -0,0 +1,36 @@ +# `application/config.py` y `application/logging_config.py` + +## Propósito +Centralizan configuración (Pydantic Settings) y logging en formato JSON. + +## Configuración (`application/config.py`) + +### Clases +- `ApiSettings`: `base_url`, `api_key`, `model`, `request_timeout_s`. +- `ImagingSettings`: `pdf_dpi`, `pdf_thread_count`, `tiff_pages`, y parámetros judiciales. +- `RetrySettings`: reintentos y backoff. +- `AppSettings`: agrega las anteriores y `csv_delimiter`. + +Se expone `settings = AppSettings()`. Lee variables desde `.env` (si existe). + +### Variables de entorno relevantes +- `EXTRACTOR_BASE_URL`, `EXTRACTOR_API_KEY`, `EXTRACTOR_MODEL`, `EXTRACTOR_TIMEOUT_S` +- `PDF_DPI`, `PDF_THREAD_COUNT`, `TIFF_PAGES` +- `JUDICIAL_FAST_FIND_DPI`, `JUDICIAL_MAIN_DPI`, `JUDICIAL_GRAYSCALE`, `JUDICIAL_VALIDATE_IMAGES` +- `RETRY_MAX_ATTEMPTS`, `RETRY_WAIT_MIN_S`, `RETRY_WAIT_MAX_S` +- `CSV_DELIMITER` + +## Logging (`application/logging_config.py`) +Configura loggers rotativos unificados: +- `logs/ips_extractor.log` (aplicación), +- `logs/llm.log` (normalización), +- `logs/api_calls.log` (llamadas a APIs), +y consola para WARNING+. + +### Uso +```python +from application.logging_config import configure_logging +configure_logging() +``` + + diff --git a/docs/application/flows.md b/docs/application/flows.md new file mode 100644 index 0000000..2e6fbd8 --- /dev/null +++ b/docs/application/flows.md @@ -0,0 +1,89 @@ +# `application/flows.py` + +## Propósito +Orquesta el procesamiento por radicado para: +- Calcular encabezados CSV unificados a partir de muestras y selección de campos. +- Extraer información de factura y, opcionalmente, preformato. +- Escribir filas en CSV y emitir JSON a la interfaz gráfica mediante callbacks. + +Incluye una heurística para inferir qué listas deben expandirse por filas y cuáles agregarse como columnas (en preformato). + +## API pública + +### `compute_csv_header_union(...) -> tuple[list[str] | None, dict[str, str]]` +Calcula el encabezado CSV común (unión) desde datos de muestra y paths seleccionados, aplicando alias y resolviendo duplicados. + +Parámetros clave: +- `sample_fact`, `sample_pre`: ejemplos (dict o compatible) para derivar columnas. +- `selected_fields_fact`, `selected_fields_pre`: paths seleccionados por documento. +- `pre_enabled`: habilita el cálculo para preformato. +- `alias_map_fact`, `alias_map_pre`: alias opcionales path->nombre. + +Devuelve: +- Lista de encabezados finales (o `None` si no hay columnas). +- Mapa `path -> alias final` aplicado. + +Notas: +- `radicado` siempre se incluye como columna. +- Duplicados de alias se desambiguarán con sufijos `(n)`. + +### `process_radicados_parallel(...) -> None` +Procesa en paralelo los radicados, realizando: +1) Extracción de datos de factura y preformato (si está habilitado). +2) Emisión de JSON via `on_output`. +3) Escritura incremental en CSV con encabezado dado (`csv_header_union`) o derivado. + +Parámetros clave: +- `service`: Debe exponer `extract_full_from_pdf(...)` y `_append_rows_to_csv(...)`. +- `root`, `keywords`, `pre_keywords`: Búsqueda de documentos por radicado. +- `selected_fields_fact`, `selected_fields_pre`: paths de salida. +- `csv_path`: Archivo CSV destino (si `None`, no escribe). +- `on_output`: Callback que recibe texto JSON por radicado. +- `on_progress`: Callback `(radicado, done, total)` para progreso. +- `instruction_invoice`, `instruction_pre`: instrucciones LLM. +- `alias_final_map`: alias finales a aplicar a encabezados/filas. + +Concurrencia: +- Usa `ThreadPoolExecutor` con `max_workers=4` para balancear IO/CPU. +- Los errores por documento se capturan y se registran sin detener el flujo. + +## Consideraciones +- Si se define `csv_header_union`, éste manda sobre encabezados derivados por radicado. +- Para preformato, la heurística `_infer_aggregate_list_paths_from_selected` selecciona la lista principal y las listas a agregar; si no hay inferencia, se consulta `profiles`. +- `on_output` y `on_progress` son opcionales y tolerantes a errores (capturados). + +## Ejemplos + +### Cálculo de encabezado +```python +from application.flows import compute_csv_header_union + +header, alias_map = compute_csv_header_union( + sample_fact={'radicado': 'x', 'items': [{'codigo': 'A1', 'cantidad': 2}]}, + sample_pre=None, + selected_fields_fact=['radicado', 'items/*/codigo', 'items/*/cantidad'], + selected_fields_pre=None, + pre_enabled=False, +) +# header ~= ['radicado', 'items/*/codigo', 'items/*/cantidad'] +``` + +### Proceso por radicado (escritura CSV) +```python +from application.flows import process_radicados_parallel + +process_radicados_parallel( + service=my_service, # Debe implementar extract_full_from_pdf y _append_rows_to_csv + root="C:/carpeta/raiz", + keywords=["FACTURA", "RADICADO"], + pre_enabled=True, + pre_keywords=["PREFORMATO"], + selected_fields_fact=['radicado', 'items/*/codigo', 'items/*/cantidad'], + selected_fields_pre=['radicado', 'dx/*/descripcion'], + csv_path="salida.csv", + on_output=lambda txt: print(txt), + on_progress=lambda r,d,t: print(f"{d}/{t} -> {r}"), +) +``` + + diff --git a/docs/application/normalizer.md b/docs/application/normalizer.md new file mode 100644 index 0000000..cb81e50 --- /dev/null +++ b/docs/application/normalizer.md @@ -0,0 +1,28 @@ +# `application/normalizer.py` + +## Propósito +Normaliza un JSON de entrada al esquema canónico basado en rutas (paths) seleccionado por el usuario, utilizando un LLM. + +## API pública + +### `build_canonical_template(paths: list[str]) -> dict` +Convierte rutas del tipo `a/b/*/c` en un objeto anidado con listas y `null`, siempre incluyendo `radicado` en la raíz. + +### `normalize_to_schema(data: dict, paths: list[str], profile_name: str, model: str | None = None) -> dict | None` +Usa un LLM para mapear `data` al template canónico generado por `build_canonical_template`. Retorna `None` si no hay API Key o si el LLM falla. + +## Consideraciones +- Control de reintentos y timeouts vía `settings.retry` y `settings.api`. +- Mantiene `radicado` si estaba presente en el `data` original. +- Registra métricas (duración, modelo, base_url) sin exponer contenido sensible. + +## Ejemplo +```python +from application.normalizer import build_canonical_template, normalize_to_schema + +paths = ["radicado", "items/*/codigo", "items/*/cantidad"] +template = build_canonical_template(paths) +normalized = normalize_to_schema({"radicado": "R-1", "items": [{"codigo": "A", "cantidad": 2}]}, paths, profile_name="invoice") +``` + + diff --git a/docs/application/profiles.md b/docs/application/profiles.md new file mode 100644 index 0000000..388c7af --- /dev/null +++ b/docs/application/profiles.md @@ -0,0 +1,26 @@ +# `application/profiles.py` + +## Propósito +Define perfiles por tipo de documento con: +- `wrap_root`: clave raíz para envolver salidas. +- `default_instruction`: instrucción por defecto para el LLM. +- `aggregate_list_paths`: rutas de listas a agregar en CSV cuando corresponda. + +## API pública + +### `get_profile(name: str) -> Profile` +Devuelve un perfil por nombre. Si no existe, usa `invoice`. + +## Perfiles +- `invoice`: sin `wrap_root`, instrucción para tablas y etiquetas. + +## Ejemplo +```python +from application.profiles import get_profile + +prof = get_profile("invoice") +inst = prof["default_instruction"] +agg = prof.get("aggregate_list_paths") +``` + + diff --git a/docs/application/prompts.md b/docs/application/prompts.md new file mode 100644 index 0000000..0df25ea --- /dev/null +++ b/docs/application/prompts.md @@ -0,0 +1,28 @@ +# `application/prompts.py` + +## Propósito +Centraliza la construcción de prompts para: +- Extracción reducida limitada a los campos seleccionados. +- Normalización de JSON al esquema canónico. + +## API pública + +### `PromptBuilder.build(selected_fields, schema, profile_name=None) -> str` +Devuelve un JSON de instrucciones para extracción, respetando exactamente las claves solicitadas. + +### `PromptBuilder.build_normalization_messages(profile_name, template, source_json) -> list[dict[str, str]]` +Construye los mensajes `system` y `user` para solicitar al LLM la normalización al `template`. + +## Ejemplo +```python +from application.prompts import PromptBuilder + +prompt = PromptBuilder.build( + ["radicado", "items/*/codigo"], + {"radicado": "str", "items/*/codigo": "str"}, + profile_name="invoice" +) +msgs = PromptBuilder.build_normalization_messages("invoice", {"radicado": None}, {"radicado": "R-1"}) +``` + + diff --git a/docs/application/service.md b/docs/application/service.md new file mode 100644 index 0000000..6bd58f2 --- /dev/null +++ b/docs/application/service.md @@ -0,0 +1,53 @@ +# `application/service.py` + +## Propósito +Fachada de alto nivel para procesar facturas: +- Convierte documentos (PDF/TIFF) a imágenes base64 según configuración. +- Llama a una estrategia de extracción multimodal (OpenAI/Ollama compatible). +- Construye un esquema a partir del primer documento y permite seleccionar campos. +- Escribe CSV incrementalmente respetando la selección de paths. + +## API pública + +### `InvoiceProcessingService.extract_full_from_pdf(pdf_path, instruction=None) -> dict` +Extrae JSON completo desde un documento. Selecciona instrucción por defecto del perfil `invoice` si no se provee y aplica fallback (DPI reducido, límite de páginas) ante fallos del backend. + +Parámetros: +- `pdf_path`: ruta al documento. +- `instruction`: instrucción (prompt) opcional. + +### `InvoiceProcessingService.build_schema_from_first(pdf_path, ...) -> (schema, data)` +Construye un esquema de rutas a partir del primer documento y devuelve también los datos extraídos. + +### `InvoiceProcessingService.process_root_with_field_selection(root_path, keywords=None, *, doc_types=None, fields=None, on_progress=None, csv_output_path=None, csv_header_override=None, instruction_override=None, on_usage=None, alias_map=None) -> list[dict]` +Procesa radicados en un directorio aplicando una selección de campos. Si `fields` es `None`, devuelve un objeto con `_needs_fields` para que el llamador abra el selector de campos. + +#### Selección de archivos por keywords/patrones + +- En modo sencillo (`keywords`), las palabras se interpretan como: + - Texto sin `*`/`?`: coincide si el nombre de archivo **contiene** ese texto (case-insensitive). + - Patrón con `*`/`?`: se evalúa usando semántica tipo Windows (`*_1`, `*_OK?.pdf`, etc.). +- En modo multi-documento (`doc_types`), cada tipo tiene su propio keyword/patrón con la misma semántica. +- Si el patrón no incluye extensión, se aplica sobre el nombre sin extensión y solo se consideran `.pdf`, `.tif`, `.tiff`. +- Si incluye una extensión válida (por ejemplo `.pdf` en `*_OK?.pdf`), se respeta exactamente esa extensión. + +## Consideraciones +- La escritura CSV respeta el orden de `csv_header_override` si se proporciona. +- Se incluyen logs detallados de tiempos y tamaños para diagnosticar problemas. + +## Ejemplos +```python +from application.service import InvoiceProcessingService +from application.strategies import OllamaExtractor + +svc = InvoiceProcessingService(OllamaExtractor()) +schema, first = svc.build_schema_from_first("ejemplo.pdf") +resultados = svc.process_root_with_field_selection( + root_path="C:/facturas", + keywords=["FACTURA"], + fields=["radicado", "items/*/codigo", "items/*/cantidad"], + csv_output_path="salida.csv", +) +``` + + diff --git a/docs/application/strategies.md b/docs/application/strategies.md new file mode 100644 index 0000000..a6b9dc5 --- /dev/null +++ b/docs/application/strategies.md @@ -0,0 +1,31 @@ +# `application/strategies.py` + +## Propósito +Define la interfaz `InvoiceExtractorStrategy` y una implementación `OllamaExtractor` para realizar llamadas a un backend compatible con OpenAI/Ollama que procese imágenes + texto. + +## API pública + +### `class InvoiceExtractorStrategy(ABC)` +Interfaz para extraer datos a partir de páginas en base64 e instrucción de texto. + +### `class OllamaExtractor(InvoiceExtractorStrategy)` +Extractor que construye prompts multimodales y llama a `chat.completions.create`. + +Métodos clave: +- `extract(images_b64: list[str], instruction: str) -> dict`: devuelve al menos `{"raw": ...}` con el texto del modelo. +- `update_config(api_key=None, base_url=None, model=None)`: actualiza y recrea el cliente interno. + +## Consideraciones +- Reintentos con `tenacity` usando la configuración de `settings.retry`. +- Registro de métricas (tokens, tiempos) en `application.api_calls` y logs propios. + +## Ejemplo +```python +from application.strategies import OllamaExtractor + +ext = OllamaExtractor() +res = ext.extract([img_b64_1, img_b64_2], "Instrucción...") +text = res["raw"] +``` + + diff --git a/docs/gui/README.md b/docs/gui/README.md new file mode 100644 index 0000000..d458a81 --- /dev/null +++ b/docs/gui/README.md @@ -0,0 +1,12 @@ +# Documentación de `gui/` + +## Índice +- [`controller.py`](./controller.md): controlador principal de la GUI. +- [`field_selector.py`](./field_selector.md): diálogo de selección de campos. +- [`view.py`](./view.md): vista principal `customtkinter`. + +## Relaciones entre módulos +- `controller` coordina `view` y llama a servicios de `application`; abre `field_selector` cuando se requieren campos. +- `view` expone callbacks, getters y helpers seguros para escribir salida desde hilos. + + diff --git a/docs/gui/controller.md b/docs/gui/controller.md new file mode 100644 index 0000000..75cf206 --- /dev/null +++ b/docs/gui/controller.md @@ -0,0 +1,32 @@ +# `gui/controller.py` + +## Propósito +Controlador principal de la GUI: enlaza la vista con los servicios de aplicación, maneja selección de carpetas/CSV, abre el selector de campos y ejecuta el procesamiento por radicado. + +## API pública + +### `class AppController` +- `__init__(view, service)`: inicializa y vincula callbacks. +- `on_select_root()`: abre diálogo de carpeta para la raíz. +- `on_select_csv()`: abre diálogo para guardar CSV. +- `on_process()`: coordina selección de campos y el procesamiento por radicado. + +## Flujo +1) Construye schema/muestra de factura cuando es necesario. +2) Abre `FieldSelectorDialog` para elegir campos (y alias). +3) Relanza el procesamiento por radicado, emite JSON y escribe CSV incremental. + +## Ejemplo +```python +from gui.view import InvoiceAppView +from gui.controller import AppController +from application.service import InvoiceProcessingService +from application.strategies import OllamaExtractor + +view = InvoiceAppView() +svc = InvoiceProcessingService(OllamaExtractor()) +controller = AppController(view, svc) +view.mainloop() +``` + + diff --git a/docs/gui/field_selector.md b/docs/gui/field_selector.md new file mode 100644 index 0000000..34c4129 --- /dev/null +++ b/docs/gui/field_selector.md @@ -0,0 +1,24 @@ +# `gui/field_selector.py` + +## Propósito +Diálogo modal para buscar y seleccionar paths del schema, con la posibilidad de asignar alias para los encabezados del CSV. + +## API pública + +### `class FieldSelectorDialog` +- `__init__(master, schema, sample=None, initial_selected=None)`: construye UI. +- `set_on_submit(callback)`: registra callback `(selected_fields, alias_map)`. + +## Interacciones +- Búsqueda en vivo por texto. +- Seleccionar todo / limpiar. +- Vista de muestra JSON del primer documento. +- Validación de alias duplicados. + +## Ejemplo +```python +dlg = FieldSelectorDialog(parent, schema, sample) +dlg.set_on_submit(lambda fields, aliases: print(fields, aliases)) +``` + + diff --git a/docs/gui/view.md b/docs/gui/view.md new file mode 100644 index 0000000..b8d4355 --- /dev/null +++ b/docs/gui/view.md @@ -0,0 +1,30 @@ +# `gui/view.py` + +## Propósito +Vista principal basada en `customtkinter` que define la interfaz de procesamiento y expone métodos para enlazar callbacks y comunicar estado. + +## API pública + +### `class InvoiceAppView` +- `set_on_select_root(cb)`, `set_on_process(cb)`, `set_on_select_csv(cb)` +- Getters: `get_root_path()`, `get_keywords()`, `get_fields()`, `get_csv_path()` +- Output: `append_output(text)`, `append_output_safe(text)` +- Estado: `set_processing(bool)` + +### Keywords / patrones de archivos + +- En la pestaña principal, cada Tipo de Documento tiene un campo de **Keyword/Patrón**. +- Se aceptan tanto palabras simples (sin `*` ni `?`) como patrones tipo Windows con `*` y `?`. +- Si el patrón **no incluye extensión**, se aplica sobre el nombre sin extensión y solo se consideran archivos `.pdf`, `.tif`, `.tiff`. +- Si el patrón **incluye extensión** válida (p. ej. `*_OK?.pdf`), se aplica sobre el nombre completo respetando esa extensión. + +## Ejemplo +```python +from gui.view import InvoiceAppView + +view = InvoiceAppView() +view.set_on_process(lambda: print("Procesar!")) +view.mainloop() +``` + + diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..448d382 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,7 @@ +"""Paquete `gui`: componentes de la interfaz gráfica basada en customtkinter. + +Incluye: +- `view`: ventana principal y widgets. +- `controller`: controlador de flujos de facturas. +- `field_selector`: diálogo para seleccionar campos y alias. +""" diff --git a/gui/controller.py b/gui/controller.py new file mode 100644 index 0000000..b556803 --- /dev/null +++ b/gui/controller.py @@ -0,0 +1,880 @@ +"""Controlador de la aplicación GUI: coordina vista y servicio. + +Gestiona selección de carpetas, ejecución en hilo de procesamiento, selector +interactivo de campos y actualización de configuración del extractor. +""" +import json +import logging +import threading +import asyncio +import tkinter.filedialog as fd +import tkinter.messagebox as mb +import os + +from core.utils import parse_comma_separated, collect_radicado_docs, collect_documents_flat +from application.logging_config import configure_logging +from gui.field_selector import FieldSelectorDialog +from application.preclassification import PreclassificationService +from application.strategies import OllamaExtractor +from application.database import DatabaseManager +from application.config import settings +from application.costs import compute_cost_from_usage, MODEL_PRICING, sum_costs +import pandas as pd + + +logger = logging.getLogger(__name__) + + +class AppController: + """Controlador principal de la GUI que orquesta la vista y el servicio. + + Se encarga de: + - Vincular callbacks de la vista. + - Disparar el flujo de procesamiento de facturas. + - Abrir el diálogo de selección de campos cuando el servicio lo solicite. + """ + def __init__(self, view, service): + """Inicializa el controlador y vincula handlers de la vista. + + Args: + view: Instancia de vista (por ejemplo, `InvoiceAppView`). + service: Servicio de facturas compatible con `process_root_with_field_selection`. + """ + # asegurar logging configurado + try: + configure_logging() + except Exception: + pass + self.view = view + self.service = service + # Acumuladores de uso + self._total_prompt_tokens = 0 + self._total_cached_tokens = 0 + self._total_completion_tokens = 0 + self._total_usd = 0.0 + # Estado para dashboard + self._hist_requests = [] # list[dict]: {radicado, phase, prompt, cached, completion, cost_usd} + self._per_doc_costs = {} # radicado -> {"extract": float|None, "normalize": float|None} + self._completed_doc_count = 0 + self._phase_tokens = { + "extract": {"input": 0, "cached": 0, "output": 0, "usd": 0.0}, + "normalize": {"input": 0, "cached": 0, "output": 0, "usd": 0.0}, + } + # Guard para evitar doble lanzamiento de worker2 (por doble click o condiciones de carrera) + self._worker2_running = False + + # Estado Preclasificación + self._last_run_id = None + self.db_manager = DatabaseManager() # Para pestaña resultados + + self.view.set_on_select_root(self.on_select_root) + try: + self.view.set_on_select_preclass_root(self.on_select_preclass_root) + self.view.set_on_run_preclassification(self.on_run_preclassification) + self.view.set_on_export_excel(self.on_export_excel) + self.view.set_on_organize_files(self.on_organize_files) + + # Resultados + self.view.set_on_res_search(self.on_res_search) + self.view.set_on_res_export(self.on_res_export) + self.view.set_on_res_refresh(self.on_res_refresh) + self.view.set_on_res_organize(self.on_res_organize_run) + self.view.set_on_res_select(self.on_res_select) + + # Carga inicial + self.on_res_refresh() + except Exception: + pass + self.view.set_on_process(self.on_process) + self.view.set_on_select_csv(self.on_select_csv) + self.view.set_on_save_settings(self.on_save_settings) + + # Mapeo de nombres de UI a IDs de modelo (y viceversa) + self.ui_to_model = { + "5.1": "gpt-5.1", + "4.1": "gpt-4.1", + "4.1 mini": "gpt-4.1-mini", + "4.1 nano": "gpt-4.1-nano" + } + self.model_to_ui = {v: k for k, v in self.ui_to_model.items()} + + # Inicializar vista con configuración actual + try: + # Llenar tabla de precios de modelos + pricing_data = self._get_model_pricing_data() + self.view.update_comparison_table(pricing_data) + + current_model = getattr(self.service.extractor, "model", "") + # Intentar mapear modelo backend -> nombre UI, si no existe usar raw + ui_name = self.model_to_ui.get(current_model, current_model) + if ui_name: + self.view.set_selected_model(ui_name) + + # Cargar concurrencia + self.view.set_concurrency_limit(settings.processing.concurrency_limit) + except Exception: + pass + + def _get_model_pricing_data(self): + """Obtiene las tarifas por 1M de tokens para cada modelo.""" + results = [] + for model_id, rates in MODEL_PRICING.items(): + results.append({ + "model": self.model_to_ui.get(model_id, model_id), + "input": rates["input"], + "cached_input": rates["cached_input"], + "output": rates["output"] + }) + # Ordenar por precio de input descendente + results.sort(key=lambda x: x["input"], reverse=True) + return results + + def on_save_settings(self): + """Actualiza la configuración del servicio (modelo LLM y concurrencia).""" + selected_name = self.view.get_selected_model() + concurrency = self.view.get_concurrency_limit() + + if not selected_name: + mb.showwarning("Configuración", "El modelo no puede estar vacío.") + return + + if concurrency <= 0: + mb.showwarning("Configuración", "El límite de concurrencia debe ser mayor a 0.") + return + + # Traducir nombre UI -> ID modelo real + real_model_id = self.ui_to_model.get(selected_name, selected_name) + + try: + # Actualizar extractor + if hasattr(self.service.extractor, "update_config"): + self.service.extractor.update_config(model=real_model_id) + + # Actualizar settings globales + settings.processing.concurrency_limit = concurrency + + mb.showinfo("Configuración", f"Configuración actualizada.\nModelo: {selected_name}\nConcurrencia: {concurrency}") + self.view.append_output_safe(f"Configuración actualizada: Modelo={real_model_id}, Concurrencia={concurrency}") + except Exception as e: + logger.exception("Error actualizando configuración") + mb.showerror("Error", f"No se pudo actualizar la configuración: {e}") + + def on_select_root(self): + """Abre un selector de carpeta y establece la ruta raíz en la vista. + + Además, si el primer documento detectado es TIFF, muestra los controles + de rango TIFF para que el usuario pueda ajustar el índice antes de procesar. + """ + selected = fd.askdirectory() + if not selected: + return + self.view.root_entry.delete(0, 'end') + self.view.root_entry.insert(0, selected) + + # Detectar si el primer documento a procesar es TIFF para esta carpeta + try: + keywords_str = self.view.get_keywords() + keywords = parse_comma_separated(keywords_str) or ["Factura_O_Documento_Equivalente", "factura"] + except Exception: + keywords = ["Factura_O_Documento_Equivalente", "factura"] + has_tiff_first = False + try: + preview_entries = collect_radicado_docs(selected, keywords) + if preview_entries: + first_path = str(preview_entries[0].get("path") or "").lower() + has_tiff_first = first_path.endswith((".tif", ".tiff")) + except Exception: + has_tiff_first = False + try: + self.view.show_tiff_range_controls(has_tiff_first) + except Exception: + pass + + def on_select_preclass_root(self): + """Abre un selector de carpeta y establece la ruta raíz para preclasificación.""" + selected = fd.askdirectory() + if not selected: + return + try: + self.view.preclass_root_entry.delete(0, 'end') + self.view.preclass_root_entry.insert(0, selected) + except Exception: + # fallback si cambia el nombre del widget + try: + setter = getattr(self.view, "set_preclass_root_path", None) + if callable(setter): + setter(selected) + except Exception: + pass + + def on_run_preclassification(self): + """Ejecuta la preclasificación en un hilo separado.""" + # 1. Leer inputs de la UI + root = self.view.get_preclass_root_path() + mode = getattr(self.view, "get_preclass_mode", lambda: "radicados")() + patterns = self.view.get_preclass_patterns() + target_info = self.view.get_preclass_target_info() + page_range = self.view.get_preclass_range() # (enabled, start, end) + mode = self.view.get_preclass_mode() + + # Validaciones básicas + if not root or not os.path.isdir(root): + mb.showwarning("Error", "Seleccione una carpeta raíz válida.") + return + + if not patterns: + mb.showwarning("Error", "Debe agregar al menos un patrón.") + return + + if target_info["enabled"] and not target_info["pattern"]: + mb.showwarning("Error", "Si habilita archivo objetivo, debe especificar un patrón de archivo.") + return + + # Bloquear UI + self.view.set_processing(True) + self.view.enable_preclass_actions(False) + self.view.append_preclass_output("Iniciando preclasificación...") + self.view.append_preclass_output(f"Raíz: {root}") + self.view.append_preclass_output(f"Modo: {mode}") + self.view.append_preclass_output(f"Patrones: {len(patterns)}") + if target_info["enabled"]: + self.view.append_preclass_output(f"Filtro: {target_info['pattern']}") + + # Instanciar servicio (usando el extractor actual) + # Nota: PreclassificationService maneja su propia instancia de DB + pre_service = PreclassificationService(self.service.extractor) + + def worker(): + try: + def on_progress(radicado, idx, total): + pct = int((idx / total) * 100) + msg = f"[{idx}/{total}] Procesando radicado: {radicado} ({pct}%)" + self.view.append_preclass_output_safe(msg) + + run_id = pre_service.run_preclassification( + root_path=root, + mode=mode, + patterns=patterns, + target_info=target_info, + page_range=page_range, + on_progress=on_progress + ) + + self.view.append_preclass_output_safe(f"\n--- Ejecución finalizada (ID: {run_id}) ---") + + # Guardar ID + self._last_run_id = run_id + def _refresh_results_tab(): + # Recargar runs, seleccionar el run recién ejecutado y listar sus resultados + try: + self.on_res_refresh() + self.view.set_res_run(run_id) + self.on_res_search() + except Exception: + pass + try: + self.view.enable_preclass_actions(True) + except Exception: + pass + self.view.after(0, _refresh_results_tab) + + # Mostrar resumen rápido + # Usar tabla results (1 fila = 1 documento), no el join con matches + docs = self.db_manager.search_results(run_id=run_id, status="Todos", text=None) + match_count = sum(1 for r in docs if r.get("status") == "MATCH") + self.view.append_preclass_output_safe(f"Total procesados: {len(docs)}") + self.view.append_preclass_output_safe(f"Coincidencias (MATCH): {match_count}") + + # Detalle opcional + for res in docs[:10]: # mostrar primeros 10 + status_icon = "✅" if res.get("status") == "MATCH" else "❌" + self.view.append_preclass_output_safe(f"{status_icon} {res.get('radicado')} ({res.get('status')})") + + if len(docs) > 10: + self.view.append_preclass_output_safe("... ver base de datos para más detalles.") + + self.view.after(0, lambda: mb.showinfo("Completado", f"Preclasificación finalizada.\nCoincidencias: {match_count}/{len(docs)}")) + + except Exception as e: + logger.exception("Error en preclasificación") + self.view.append_preclass_output_safe(f"ERROR CRÍTICO: {str(e)}") + self.view.after(0, lambda: mb.showerror("Error", f"Ocurrió un error: {e}")) + finally: + self.view.set_processing(False) + + threading.Thread(target=worker, daemon=True).start() + + def on_export_excel(self): + """Exporta los resultados de la última preclasificación a Excel.""" + if not self._last_run_id: + mb.showwarning("Aviso", "No hay resultados de preclasificación recientes para exportar.") + return + + path = fd.asksaveasfilename( + defaultextension=".xlsx", + filetypes=[("Excel", "*.xlsx"), ("CSV", "*.csv"), ("Todos", "*.*")] + ) + if not path: + return + + try: + pre_service = PreclassificationService(self.service.extractor) + ok = pre_service.export_to_excel(self._last_run_id, path) + if ok: + mb.showinfo("Exportar", "Archivo exportado correctamente.") + else: + mb.showerror("Exportar", "No se encontraron datos para exportar.") + except Exception as e: + logger.exception("Error exportando excel") + mb.showerror("Error", f"Error al exportar: {e}") + + def on_organize_files(self): + """Organiza los archivos MATCH en carpetas (Run actual).""" + if not self._last_run_id: + mb.showwarning("Aviso", "No hay resultados para organizar.") + return + self._organize_run_generic(self._last_run_id) + + def _organize_run_generic(self, run_id: int): + target_dir = fd.askdirectory(title="Seleccionar carpeta de destino para organizar") + if not target_dir: + return + + if not mb.askyesno("Confirmar", "¿Está seguro de copiar y organizar los archivos en la carpeta seleccionada?\nEsta acción creará una estructura de carpetas basada en los datos extraídos."): + return + + try: + # Usamos append_preclass_output si estamos en esa pestaña, pero desde Resultados? + # El metodo de view inserta en el widget de log. Si estamos en tab Resultados, no se ve. + # Pero no importa, mostramos mb.showinfo al final. + pre_service = PreclassificationService(self.service.extractor) + ok_count, err_count = pre_service.organize_files(run_id, target_dir) + + msg = f"Organización finalizada.\nCopiados exitosos: {ok_count}\nErrores: {err_count}" + mb.showinfo("Organizar", msg) + except Exception as e: + logger.exception("Error organizando archivos") + mb.showerror("Error", f"Error al organizar: {e}") + + # --- Pestaña Resultados --- + def on_res_select(self, event): + """Muestra detalles del resultado seleccionado.""" + selection = self.view.tree_results.selection() + if not selection: + return + + # values: (ID, Run, Fecha, Radicado, Estado, Archivo) + item_vals = self.view.tree_results.item(selection[0], "values") + if not item_vals: + return + + try: + res_id = int(item_vals[0]) + matches = self.db_manager.get_matches_for_result(res_id) + self.view.update_res_matches_table(matches) + except Exception as e: + logger.error(f"Error cargando detalle: {e}") + + def on_res_organize_run(self): + """Organiza archivos del Run seleccionado en el filtro.""" + filters = self.view.get_res_filters() + run_val = filters["run_id"] + + if run_val == "Todos" or not run_val: + mb.showwarning("Organizar", "Por favor seleccione un 'Run ID' específico en el filtro para organizar.") + return + + try: + run_id = int(run_val) + self._organize_run_generic(run_id) + except ValueError: + mb.showerror("Error", "ID de Run inválido.") + + def on_res_refresh(self): + """Refresca la lista de Runs en el combo.""" + try: + runs = self.db_manager.get_all_runs() + ids = [r["id"] for r in runs] + self.view.update_res_runs_combo(ids) + # Tambien refrescar tabla + self.on_res_search() + except Exception as e: + logger.error(f"Error refrescando runs: {e}") + + def on_res_search(self): + """Busca resultados con los filtros actuales.""" + filters = self.view.get_res_filters() + run_id = None + if filters["run_id"] != "Todos": + try: + run_id = int(filters["run_id"]) + except: pass + + try: + rows = self.db_manager.search_results( + run_id=run_id, + status=filters["status"], + text=filters["text"] + ) + self.view.update_res_table(rows) + except Exception as e: + logger.error(f"Error buscando resultados: {e}") + mb.showerror("Error", f"Error en búsqueda: {e}") + + def on_res_export(self): + """Exporta la vista actual de resultados a Excel.""" + path = fd.asksaveasfilename( + defaultextension=".xlsx", + filetypes=[("Excel", "*.xlsx"), ("CSV", "*.csv")] + ) + if not path: + return + + # Obtener los datos actuales (repite la busqueda) + # Podriamos optimizar guardando el ultimo result set + try: + filters = self.view.get_res_filters() + run_id = None + if filters["run_id"] != "Todos": + try: run_id = int(filters["run_id"]) + except: pass + + rows = self.db_manager.search_results( + run_id=run_id, + status=filters["status"], + text=filters["text"] + ) + + if not rows: + mb.showwarning("Exportar", "No hay datos para exportar.") + return + + df = pd.DataFrame(rows) + if path.endswith(".csv"): + df.to_csv(path, index=False, sep=";") + else: + df.to_excel(path, index=False) + + mb.showinfo("Exportar", "Datos exportados correctamente.") + except Exception as e: + logger.exception("Error exportando vista") + mb.showerror("Error", f"Error al exportar: {e}") + + def on_select_csv(self): + """Abre un diálogo de guardado y establece la ruta del CSV de salida.""" + selected = fd.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV", "*.csv"), ("Todos", "*.*")]) + if selected: + self.view.csv_entry.delete(0, 'end') + self.view.csv_entry.insert(0, selected) + + def on_process(self): + """Inicia el flujo de procesamiento por radicado (solo facturas) en un hilo de fondo. + + - Si el servicio requiere selección de campos, abre `FieldSelectorDialog` y relanza. + - Emite resultados en el área de salida y opcionalmente escribe CSV incremental. + """ + root = self.view.get_root_path() + # Tipos de Documento (multi-doc) + doc_types = [] + try: + doc_types = self.view.get_doc_types() + except Exception: + doc_types = [] + if doc_types: + try: + ok, msg = self.view.validate_doc_types() + except Exception: + ok, msg = True, None + if not ok: + self.view.append_output_safe(msg or "Tipos de Documento inválidos.") + return + keywords_str = self.view.get_keywords() + fields_str = self.view.get_fields() + structure_mode = self.view.get_process_mode() + + if structure_mode == "general": + # En modo general, si está vacío o es el default, procesar todo (None) + # Si el usuario escribió algo específico, usarlo. + kws = parse_comma_separated(keywords_str) + # Check if it matches default legacy keywords to avoid accidental filtering + if kws == ["Factura_O_Documento_Equivalente", "factura"]: + keywords = None + else: + keywords = kws if kws else None + else: + keywords = parse_comma_separated(keywords_str) or ["Factura_O_Documento_Equivalente", "factura"] + + fields = parse_comma_separated(fields_str) if fields_str else None + + # Validar ruta antes de deshabilitar la UI + if not root or not os.path.isdir(root): + self.view.append_output_safe(f"Error: La ruta raíz no es un directorio: {root or ''}") + return + + # Si es modo general, ignorar doc_types + if structure_mode == "general": + doc_types = [] + + # Determinar si el primer documento a procesar es TIFF; si no lo es, ocultar + # los controles de rango TIFF y no aplicar ningún recorte. + tiff_page_range = None + has_tiff_first = False + try: + if structure_mode == "general": + preview_entries = collect_documents_flat(root, keywords) + else: + preview_entries = collect_radicado_docs(root, keywords) + + if preview_entries: + first_path = str(preview_entries[0].get("path") or "").lower() + has_tiff_first = first_path.endswith((".tif", ".tiff")) + except Exception: + has_tiff_first = False + try: + # Mostrar/ocultar controles en función de si el primer doc es TIFF + self.view.show_tiff_range_controls(has_tiff_first) + except Exception: + pass + + if has_tiff_first: + # Rango opcional de páginas TIFF (1-based; 0 = sin límite) + try: + tiff_start_raw = int(getattr(self.view, "get_tiff_start_page", lambda: 0)() or 0) + except Exception: + tiff_start_raw = 0 + try: + tiff_end_raw = int(getattr(self.view, "get_tiff_end_page", lambda: 0)() or 0) + except Exception: + tiff_end_raw = 0 + + if tiff_start_raw < 0 or tiff_end_raw < 0: + self.view.append_output_safe("Error: Las páginas TIFF deben ser mayores o iguales a 0.") + return + + tiff_start = tiff_start_raw if tiff_start_raw > 0 else None + tiff_end = tiff_end_raw if tiff_end_raw > 0 else None + if tiff_start is not None and tiff_end is not None and tiff_start > tiff_end: + self.view.append_output_safe("Error: La página de inicio TIFF no puede ser mayor que la página de fin.") + return + # Si ambos son 0 (None/None), el usuario quiere "todas las páginas": + # usamos el centinela (None, None) para indicar "no recortar TIFF". + if tiff_start is None and tiff_end is None: + tiff_page_range = (None, None) + else: + tiff_page_range = (tiff_start, tiff_end) + + def worker(): + resultados = None + if doc_types: + try: + self.view.append_output_safe(f"Procesando raíz: {root} | tipos: {[(dt.get('name'), dt.get('keyword')) for dt in doc_types]}") + except Exception: + self.view.append_output_safe(f"Procesando raíz: {root} | tipos: {len(doc_types)}") + else: + self.view.append_output_safe(f"Procesando raíz: {root} | keywords: {keywords}") + # limpiar pestaña Uso + try: + self._total_prompt_tokens = 0 + self._total_cached_tokens = 0 + self._total_completion_tokens = 0 + self._total_usd = 0.0 + self._hist_requests.clear() + self._per_doc_costs.clear() + self._completed_doc_count = 0 + self._phase_tokens = { + "extract": {"input": 0, "cached": 0, "output": 0, "usd": 0.0}, + "normalize": {"input": 0, "cached": 0, "output": 0, "usd": 0.0}, + } + self.view.clear_usage() + except Exception: + pass + try: + def on_progress(radicado, idx, total): + pct = int((idx / total) * 100) + self.view.append_output_safe(f"Progreso: {idx}/{total} ({pct}%) - radicado {radicado}") + + csv_path = self.view.get_csv_path() or None + + # Primera ejecución: si fields es None, el servicio devolverá _needs_fields + if doc_types: + resultados = self.service.process_root_with_field_selection( + root_path=root, + doc_types=doc_types, + fields=fields, + on_progress=on_progress, + csv_output_path=csv_path, + tiff_page_range=tiff_page_range, + structure_mode=structure_mode, + ) + else: + resultados = self.service.process_root_with_field_selection( + root_path=root, + keywords=keywords, + fields=fields, + on_progress=on_progress, + csv_output_path=csv_path, + tiff_page_range=tiff_page_range, + structure_mode=structure_mode, + ) + + # Si requiere selección de campos abrir diálogo; de lo contrario, mostrar resultados + if resultados and isinstance(resultados, list) and isinstance(resultados[0], dict) and resultados[0].get('_needs_fields'): + schema = resultados[0].get('schema') + sample = resultados[0].get('first_full') + first_pdf_path = resultados[0].get('_source_pdf') or "" + schema_by_type = resultados[0].get('schema_by_type') + first_full_by_type = resultados[0].get('first_full_by_type') + first_radicado = resultados[0].get('first_radicado') or "" + combined_schema = None + combined_sample = None + if isinstance(schema_by_type, dict) and schema_by_type: + combined_schema = {} + try: + for tname, sch in schema_by_type.items(): + if not isinstance(sch, dict): + continue + for path, typ in sch.items(): + combined_schema[f"{tname}/{path}"] = typ + except Exception: + combined_schema = {} + # Construir muestra combinada para el botón "Ver muestra" + try: + combined_sample = {"radicado": first_radicado} + if isinstance(first_full_by_type, dict): + for tname, s in first_full_by_type.items(): + combined_sample[tname] = s or {} + except Exception: + combined_sample = None + + def on_submit(selected_fields, alias_map): + if not selected_fields: + self.view.append_output_safe("No se seleccionaron campos.") + self.view.set_processing(False) + return + # Estimar tokens/costos para toda la carpeta en hilo de fondo y pedir confirmación en hilo principal + def do_estimate_and_prompt(): + try: + if doc_types and schema_by_type: + est = self.service.estimate_tokens_and_cost( + root_path=root, + doc_types=doc_types, + schema_by_type=schema_by_type, + selected_paths=selected_fields, + structure_mode=structure_mode, + ) + else: + est = self.service.estimate_tokens_and_cost( + root_path=root, + keywords=keywords, + schema=schema, + selected_fields=selected_fields, + structure_mode=structure_mode, + ) + total_docs = est.get("total_docs", 0) + per_doc_cost = est.get("per_doc_cost", {}) or {} + estimated_total = est.get("estimated_total_cost", {}) or {} + msg = ( + f"Se procesarán {total_docs} documentos.\n\n" + f"Costo estimado por documento (USD): {per_doc_cost.get('total_usd', 'N/D')}\n" + f"Costo estimado total (USD): {estimated_total.get('total_usd', 'N/D')}\n\n" + f"¿Desea continuar?" + ) + except Exception as e_est: + msg = f"No se pudo calcular la estimación ({e_est}). ¿Desea continuar sin estimación?" + + def prompt_and_continue(): + ok = mb.askyesno("Confirmar estimación", msg) + if not ok: + self.view.set_processing(False) + return + # Evitar doble arranque + if self._worker2_running: + return + self._worker2_running = True + # Si acepta, lanzar procesamiento en otro hilo + threading.Thread(target=worker2, daemon=True).start() + + # Volver al hilo principal para mostrar el cuadro de diálogo + self.view.after(0, prompt_and_continue) + + threading.Thread(target=do_estimate_and_prompt, daemon=True).start() + def worker2(): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def on_error_retry(error): + # Este callback corre en el loop de asyncio (worker thread) + future = loop.create_future() + + def ask(): + msg = ( + f"⚠️ Error de Cuota o Límite (429):\n\n{error}\n\n" + "¿Desea PAUSAR el proceso, añadir fondos/esperar y REINTENTAR este radicado?\n\n" + "Si selecciona 'No', el proceso se detendrá y se guardará lo avanzado hasta ahora." + ) + res = mb.askyesno("Límite de Cuota Excedido", msg) + # Regresar el resultado al loop de asyncio + loop.call_soon_threadsafe(future.set_result, res) + + self.view.after(0, ask) + return await future + + # callback para uso por petición + def on_usage(phase, radicado, usage, cost): + try: + cached = 0 + if isinstance(usage, dict): + pt = int(usage.get("prompt_tokens") or 0) + ct = int(usage.get("completion_tokens") or 0) + details = usage.get("prompt_tokens_details") or {} + cached = int(details.get("cached_tokens") or 0) + self._total_prompt_tokens += pt + self._total_cached_tokens += cached + self._total_completion_tokens += ct + if isinstance(cost, dict): + self._total_usd += float(cost.get("total_usd") or 0.0) + # Registrar histórico + per_cost = float((cost or {}).get("total_usd") or 0.0) + self._hist_requests.append({ + "radicado": radicado, "phase": phase, + "prompt": int((usage or {}).get("prompt_tokens") or 0), + "cached": cached, + "completion": int((usage or {}).get("completion_tokens") or 0), + "cost_usd": per_cost, + }) + # Actualizar per-doc y graficar cuando tengamos ambas fases + bucket = self._per_doc_costs.get(radicado) or {"extract": None, "normalize": None} + bucket[phase] = per_cost + self._per_doc_costs[radicado] = bucket + if bucket.get("extract") is not None and bucket.get("normalize") is not None: + self._completed_doc_count += 1 + total_cost_doc = float(bucket["extract"] or 0.0) + float(bucket["normalize"] or 0.0) + # actualizar línea en hilo principal + self.view.after(0, lambda idx=self._completed_doc_count, val=total_cost_doc: self.view.update_cost_line(idx, val)) + # Actualizar totales por fase + if phase in self._phase_tokens: + self._phase_tokens[phase]["input"] += int((usage or {}).get("prompt_tokens") or 0) + self._phase_tokens[phase]["cached"] += cached + self._phase_tokens[phase]["output"] += int((usage or {}).get("completion_tokens") or 0) + self._phase_tokens[phase]["usd"] += per_cost + # Actualizar barras por fase + ex = self._phase_tokens["extract"] + nz = self._phase_tokens["normalize"] + self.view.after(0, lambda: self.view.update_token_bars( + ex["input"], ex["cached"], ex["output"], nz["input"], nz["cached"], nz["output"] + )) + # Actualizar KPIs + overall = { + "input": self._total_prompt_tokens, + "cached": self._total_cached_tokens, + "output": self._total_completion_tokens, + "usd": self._total_usd, + } + self.view.after(0, lambda ov=overall, exd=ex.copy(), nzd=nz.copy(): self.view.update_kpis(ov, exd, nzd)) + # Añadir fila textual + self.view.after(0, lambda p=phase, r=radicado, u=usage, c=cost: self.view.append_usage_row(p, r, u, c)) + # actualizar totales + self.view.set_usage_totals( + prompt_tokens=self._total_prompt_tokens, + cached_tokens=self._total_cached_tokens, + completion_tokens=self._total_completion_tokens, + total_usd=self._total_usd, + ) + except Exception: + pass + if doc_types: + res = self.service.process_root_with_field_selection( + root_path=root, + doc_types=doc_types, + fields=selected_fields, + on_progress=on_progress, + csv_output_path=csv_path, + on_usage=on_usage, + on_error_retry=on_error_retry, + alias_map=alias_map, + tiff_page_range=tiff_page_range, + structure_mode=structure_mode, + ) + else: + res = self.service.process_root_with_field_selection( + root_path=root, + keywords=keywords, + fields=selected_fields, + on_progress=on_progress, + csv_output_path=csv_path, + on_usage=on_usage, + on_error_retry=on_error_retry, + alias_map=alias_map, + tiff_page_range=tiff_page_range, + structure_mode=structure_mode, + ) + self.view.append_output_safe(json.dumps(res, ensure_ascii=False, indent=2)) + except Exception as e2: + logging.exception("Error en reproceso") + self.view.append_output_safe(f"Error: {e2}") + finally: + # Liberar guard al finalizar + self._worker2_running = False + self.view.set_processing(False) + self.view.after(0, lambda: mb.showinfo("Proceso Finalizado", "El procesamiento ha finalizado.")) + + # Preparar callback de reprocesamiento + reprocess_cb = None + if doc_types: + def _reprocess_multi(): + s_by_type, f_full_by_type, f_rad = self.service.build_schema_from_first_for_types( + root, doc_types, tiff_page_range=tiff_page_range, structure_mode=structure_mode + ) + c_schema = {} + try: + for tname, sch in s_by_type.items(): + if not isinstance(sch, dict): continue + for p, typ in sch.items(): + c_schema[f"{tname}/{p}"] = typ + except Exception: pass + + c_sample = {"radicado": f_rad} + try: + for tname, s in f_full_by_type.items(): + c_sample[tname] = s or {} + except Exception: pass + return c_schema, c_sample + reprocess_cb = _reprocess_multi + elif first_pdf_path: + def _reprocess_single(): + return self.service.build_schema_from_first(first_pdf_path, tiff_page_range=tiff_page_range) + reprocess_cb = _reprocess_single + + # Asegurar que siempre pasamos un schema dict (aunque sea vacío) al selector + effective_schema = combined_schema if combined_schema is not None else (schema or {}) + effective_sample = combined_sample if combined_sample is not None else sample + + self.view.after(0, lambda: self._open_field_selector(effective_schema, effective_sample, on_submit, reprocess_cb)) + else: + self.view.append_output_safe(json.dumps(resultados, ensure_ascii=False, indent=2)) + except Exception as e: + logger.exception("Error en procesamiento") + self.view.append_output_safe(f"Error: {e}") + finally: + # Si se abrió selector, se reactivará al terminar worker2 + if not (isinstance(resultados, list) and resultados and isinstance(resultados[0], dict) and resultados[0].get('_needs_fields')): + self.view.set_processing(False) + self.view.after(0, lambda: mb.showinfo("Proceso Finalizado", "El procesamiento ha finalizado.")) + + self.view.set_processing(True) + threading.Thread(target=worker, daemon=True).start() + + def _open_field_selector(self, schema, sample, on_submit, on_reprocess=None): + """Abre el diálogo de selección de campos. + + Args: + schema: Mapa path->tipo que ofrece el servicio para ayudar a seleccionar. + sample: JSON de muestra del primer documento. + on_submit: Callback `(selected_fields, alias_map)` a invocar al aceptar. + on_reprocess: Callback opcional para reprocesar muestra y schema. + """ + dlg = FieldSelectorDialog(self.view, schema=schema, sample=sample) + dlg.set_on_submit(on_submit) + if on_reprocess: + dlg.set_on_reprocess(on_reprocess) diff --git a/gui/field_selector.py b/gui/field_selector.py new file mode 100644 index 0000000..c5b540b --- /dev/null +++ b/gui/field_selector.py @@ -0,0 +1,363 @@ +"""Diálogo de selección de campos basado en `customtkinter`. + +Permite buscar, seleccionar/deseleccionar y revisar una muestra de la primera +factura para facilitar la elección de paths del schema. +""" +from __future__ import annotations + +import json +from typing import Dict, List, Callable, Optional + +import customtkinter as ctk +import threading +import tkinter.messagebox as mb + + +class FieldSelectorDialog(ctk.CTkToplevel): + """Ventana modal para elegir campos del schema a extraer.""" + def __init__(self, master, schema: Dict[str, str], sample: Optional[dict] = None, initial_selected: Optional[List[str]] = None): + """Crea el diálogo con lista scrolleable y búsqueda. + + Args: + master: Ventana padre. + schema: Mapa path->tipo (ayuda visual en etiqueta del checkbox). + sample: JSON de muestra para vista previa. + initial_selected: Paths seleccionados inicialmente. + """ + super().__init__(master) + self.title("Seleccionar campos del schema") + self.geometry("800x600") + self.transient(master) + self.grab_set() + + self._schema = schema + self._sample = sample + self._on_submit: Optional[Callable[[List[str], Dict[str, str]], None]] = None + self._on_reprocess: Optional[Callable[[], tuple[Dict[str, str], dict]]] = None + + self.search_entry = ctk.CTkEntry(self, placeholder_text="Buscar campo...") + self.search_entry.grid(row=0, column=0, columnspan=3, sticky="we", padx=10, pady=(10, 5)) + self.search_entry.bind("", self._on_search) + + self.scroll = ctk.CTkScrollableFrame(self) + self.scroll.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=10, pady=5) + + self.select_all_btn = ctk.CTkButton(self, text="Seleccionar todo", command=self._select_all) + self.select_all_btn.grid(row=2, column=0, padx=10, pady=10, sticky="w") + self.clear_btn = ctk.CTkButton(self, text="Limpiar", command=self._clear_all) + self.clear_btn.grid(row=2, column=1, padx=10, pady=10, sticky="w") + self._right_btns = ctk.CTkFrame(self) + self._right_btns.grid(row=2, column=2, padx=10, pady=10, sticky="e") + self.sample_btn = ctk.CTkButton(self._right_btns, text="Ver muestra", command=self._show_sample) + self.sample_btn.pack(side="left", padx=(0, 8)) + self.reprocess_btn = ctk.CTkButton(self._right_btns, text="Reprocesar", command=self._reprocess) + self.reprocess_btn.pack(side="left") + + self.cancel_btn = ctk.CTkButton(self, text="Cancelar", command=self._cancel) + self.cancel_btn.grid(row=3, column=0, padx=10, pady=(0, 10), sticky="w") + self.ok_btn = ctk.CTkButton(self, text="Aceptar", command=self._accept) + self.ok_btn.grid(row=3, column=2, padx=10, pady=(0, 10), sticky="e") + + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=0) + self.grid_columnconfigure(2, weight=1) + self.grid_rowconfigure(1, weight=1) + + self._vars: Dict[str, ctk.BooleanVar] = {} + self._rows: Dict[str, ctk.CTkFrame] = {} + self._alias_entries: Dict[str, ctk.CTkEntry] = {} + self._group_labels: Dict[str, ctk.CTkLabel] = {} + self._group_paths: Dict[str, list[str]] = {} + + initial_set = set(initial_selected or []) + # Construir grupos por prefijo (Tipo) cuando el path contiene '/' + grouped: Dict[str, list[tuple[str, str]]] = {} + for path, typ in sorted(self._schema.items(), key=lambda x: x[0]): + if '/' in path: + group, sub = path.split('/', 1) + else: + group, sub = "General", path + grouped.setdefault(group, []).append((path, typ)) + # Crear filas agrupadas + rows_container = self.scroll + for group_name, items in grouped.items(): + # Encabezado del grupo + glabel = ctk.CTkLabel(rows_container, text=group_name, anchor="w") + glabel.pack(anchor="w", fill="x", padx=8, pady=(6, 2)) + self._group_labels[group_name] = glabel + self._group_paths[group_name] = [] + # Filas del grupo + for path, typ in items: + var = ctk.BooleanVar(value=(path in initial_set)) + row = ctk.CTkFrame(rows_container) + row.pack(anchor="w", fill="x", padx=8, pady=2) + cb = ctk.CTkCheckBox(row, text=f"{path} ({typ})", variable=var) + cb.grid(row=0, column=0, sticky="w") + alias_entry = ctk.CTkEntry(row, width=260, placeholder_text="Nombre en CSV") + try: + alias_entry.insert(0, path) + except Exception: + pass + alias_entry.grid(row=0, column=1, padx=(12, 0)) + try: + alias_entry.bind("", lambda _e: self._validate_aliases()) + except Exception: + pass + def _toggle(ent: ctk.CTkEntry, v: ctk.BooleanVar = var, p: str = path): + try: + if p == 'radicado': + ent.grid_remove() + else: + if v.get(): + ent.grid(row=0, column=1, padx=(12, 0)) + else: + ent.grid_remove() + except Exception: + pass + self._validate_aliases() + _toggle(alias_entry) + cb.configure(command=lambda e=alias_entry, v=var, p=path: _toggle(e, v, p)) + self._vars[path] = var + self._rows[path] = row + self._alias_entries[path] = alias_entry + self._group_paths[group_name].append(path) + self._validate_aliases() + + def set_on_submit(self, callback: Callable[[List[str], Dict[str, str]], None]) -> None: + """Registra el callback a invocar con (selected_fields, alias_map).""" + self._on_submit = callback + + def set_on_reprocess(self, callback: Callable[[], tuple[Dict[str, str], dict]]) -> None: + """Registra el callback a invocar para reprocesar y actualizar (schema, sample).""" + self._on_reprocess = callback + + def _on_search(self, _event=None): + """Filtra filas visibles por coincidencia de texto en el path.""" + query = self.search_entry.get().strip().lower() + + # Lista ordenada de grupos según se crearon + ordered_groups = list(self._group_labels.keys()) + + for group_name in ordered_groups: + paths = self._group_paths.get(group_name, []) + # Determinar qué filas de este grupo coinciden + visible_paths = [] + for p in paths: + if query in p.lower(): + visible_paths.append(p) + + # Si hay filas visibles, mostrar grupo y esas filas; si no, ocultar todo el grupo + lbl = self._group_labels.get(group_name) + if visible_paths: + if lbl: + lbl.pack(anchor="w", fill="x", padx=8, pady=(6, 2)) + for p in paths: + row = self._rows.get(p) + if not row: continue + if p in visible_paths: + row.pack(anchor="w", fill="x", padx=8, pady=2) + else: + row.pack_forget() + else: + if lbl: + lbl.pack_forget() + for p in paths: + row = self._rows.get(p) + if row: + row.pack_forget() + + def _select_all(self): + """Marca todos los campos y muestra las entradas de alias (excepto radicado).""" + for path, var in self._vars.items(): + var.set(True) + try: + ent = self._alias_entries.get(path) + if ent is not None: + if path == 'radicado': + ent.grid_remove() + else: + ent.grid(row=0, column=1, padx=(12, 0)) + except Exception: + pass + self._validate_aliases() + + def _clear_all(self): + """Desmarca todos los campos y oculta entradas de alias.""" + for path, var in self._vars.items(): + var.set(False) + try: + ent = self._alias_entries.get(path) + if ent is not None: + ent.grid_remove() + except Exception: + pass + self._validate_aliases() + + def _show_sample(self): + """Muestra la muestra JSON en un toplevel de solo lectura (si existe).""" + if self._sample is None: + return + win = ctk.CTkToplevel(self) + win.title("Muestra de la primera factura") + win.geometry("700x500") + win.transient(self) + win.lift() + try: + win.attributes("-topmost", True) + # mantener al frente un instante y luego permitir comportamiento normal + win.after(300, lambda: win.attributes("-topmost", False)) + except Exception: + pass + win.focus_force() + txt = ctk.CTkTextbox(win) + txt.pack(expand=True, fill="both", padx=10, pady=10) + txt.insert("end", json.dumps(self._sample, ensure_ascii=False, indent=2)) + txt.configure(state="disabled") + + def _accept(self): + """Construye selección y alias y cierra devolviendo datos al callback.""" + selected = [p for p, v in self._vars.items() if v.get()] + alias_map: Dict[str, str] = {} + for p in selected: + ent = self._alias_entries.get(p) + alias = None + try: + alias = (ent.get().strip() if ent is not None else "") + except Exception: + alias = "" + if not alias: + alias = p + # 'radicado' permanece fijo si aparece + if p == 'radicado': + alias = 'radicado' + alias_map[p] = alias + if self._on_submit: + self._on_submit(selected, alias_map) + self.destroy() + + def _cancel(self): + """Cancela la selección (devuelve listas vacías) y cierra el diálogo.""" + if self._on_submit: + self._on_submit([], {}) + self.destroy() + + def _validate_aliases(self): + """Valida alias. (Validación de duplicados eliminada para permitir fusión de columnas).""" + # Siempre permitir aceptar, ya que los duplicados se fusionarán en el backend. + try: + self.ok_btn.configure(state="normal") + except Exception: + pass + + + # --- Reprocesar (reextraer primer documento y refrescar lista) --- + def _set_busy(self, busy: bool) -> None: + """Habilita/deshabilita controles y cambia texto del botón Reprocesar.""" + try: + state = "disabled" if busy else "normal" + self.select_all_btn.configure(state=state) + self.clear_btn.configure(state=state) + self.sample_btn.configure(state=state) + self.ok_btn.configure(state=state) + self.cancel_btn.configure(state=state) + self.search_entry.configure(state=state) + self.reprocess_btn.configure(text=("Reprocesando…" if busy else "Reprocesar"), state=("disabled" if busy else "normal")) + except Exception: + pass + + def _reprocess(self): + """Ejecuta reproceso en hilo de fondo y actualiza schema/muestra preservando selección y alias existentes.""" + if self._on_reprocess is None: + return + self._set_busy(True) + + def worker(): + try: + new_schema, new_sample = self._on_reprocess() + def apply_updates(): + self._apply_new_schema_sample(new_schema, new_sample) + self._set_busy(False) + self.after(0, apply_updates) + except Exception as e: + def on_err(): + try: + mb.showerror("Error de reproceso", f"No se pudo reprocesar: {e}") + except Exception: + pass + self._set_busy(False) + self.after(0, on_err) + + threading.Thread(target=worker, daemon=True).start() + + def _apply_new_schema_sample(self, new_schema: Dict[str, str], new_sample: dict) -> None: + """Reconstruye la lista de campos preservando selección y alias cuando sea posible.""" + # Guardar selección y alias actuales + prev_selected = {p for p, v in self._vars.items() if v.get()} + prev_alias: Dict[str, str] = {} + for p, ent in self._alias_entries.items(): + try: + val = (ent.get().strip() if ent is not None else "") or p + except Exception: + val = p + if p == 'radicado': + val = 'radicado' + prev_alias[p] = val + + # Limpiar filas actuales + try: + for _, frame in self._rows.items(): + frame.destroy() + except Exception: + pass + self._vars.clear() + self._rows.clear() + self._alias_entries.clear() + + # Asignar nuevos datos + self._schema = new_schema or {} + self._sample = new_sample + + rows_container = self.scroll + for path in sorted(self._schema.keys()): + var = ctk.BooleanVar(value=(path in prev_selected)) + row = ctk.CTkFrame(rows_container) + row.pack(anchor="w", fill="x", padx=8, pady=2) + cb = ctk.CTkCheckBox(row, text=f"{path} ({self._schema[path]})", variable=var) + cb.grid(row=0, column=0, sticky="w") + alias_entry = ctk.CTkEntry(row, width=260, placeholder_text="Nombre en CSV") + # Establecer alias previo si existe, si no por defecto al path + alias_value = prev_alias.get(path, path) + try: + alias_entry.insert(0, alias_value) + except Exception: + pass + alias_entry.grid(row=0, column=1, padx=(12, 0)) + try: + alias_entry.bind("", lambda _e: self._validate_aliases()) + except Exception: + pass + def _toggle(ent: ctk.CTkEntry, v: ctk.BooleanVar = var, p: str = path): + try: + if p == 'radicado': + ent.grid_remove() + else: + if v.get(): + ent.grid(row=0, column=1, padx=(12, 0)) + else: + ent.grid_remove() + except Exception: + pass + self._validate_aliases() + _toggle(alias_entry) + cb.configure(command=lambda e=alias_entry, v=var, p=path: _toggle(e, v, p)) + self._vars[path] = var + self._rows[path] = row + self._alias_entries[path] = alias_entry + + # Revalidar y re-aplicar filtro de búsqueda actual + self._validate_aliases() + try: + self._on_search() + except Exception: + pass + diff --git a/gui/view.py b/gui/view.py new file mode 100644 index 0000000..957fe79 --- /dev/null +++ b/gui/view.py @@ -0,0 +1,1238 @@ +"""Vista principal de la aplicación GUI con `customtkinter`. + +Expone métodos para enlazar callbacks, leer entradas y escribir salida en un +área de texto, además de habilitar/deshabilitar controles durante el proceso. +""" +import os # Necesario para basename en update_res_table +import customtkinter as ctk + +# matplotlib es opcional +try: + import matplotlib + matplotlib.use("Agg") # backend no interactivo para render en canvas + from matplotlib.figure import Figure + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + HAS_MPL = True +except Exception: + HAS_MPL = False + + +class InvoiceAppView(ctk.CTk): + """Ventana principal con pestañas de Procesar y Configuración.""" + def __init__(self): + """Construye la ventana principal y sus pestañas/controles.""" + super().__init__() + self.title("IPSExtractor - Procesador de Documentos") + self.geometry("1100x800") + + ctk.set_appearance_mode("System") + ctk.set_default_color_theme("blue") + + # Fuentes personalizadas + self.font_title = ctk.CTkFont(family="Roboto", size=22, weight="bold") + self.font_section = ctk.CTkFont(family="Roboto", size=16, weight="bold") + self.font_ui = ctk.CTkFont(family="Roboto", size=13) + self.font_mono = ctk.CTkFont(family="Consolas", size=12) + + # Layout principal + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + + # TabView principal con estilo + self.tabs = ctk.CTkTabview(self, width=1000, height=700) + self.tabs.grid(row=0, column=0, sticky="nsew", padx=20, pady=20) + + self.tab_main = self.tabs.add("Procesar Documentos") + self.tab_preclass = self.tabs.add("Preclasificación") + self.tab_results = self.tabs.add("Resultados") + self.tab_settings = self.tabs.add("Configuración") + self.tab_usage = self.tabs.add("Dashboard & Métricas") + + # ========================================================================= + # --- TAB: PROCESAR DOCUMENTOS --- + # ========================================================================= + self.tab_main.grid_columnconfigure(0, weight=1) + self.tab_main.grid_rowconfigure(0, weight=1) + + # Contenedor Scrollable Principal (Todo el contenido aquí) + self.scroll_container = ctk.CTkScrollableFrame(self.tab_main, fg_color="transparent") + self.scroll_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + self.scroll_container.grid_columnconfigure(0, weight=1) + + # --- Sección Rutas --- + self.frame_paths = ctk.CTkFrame(self.scroll_container) + self.frame_paths.grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 2)) + self.frame_paths.grid_columnconfigure(1, weight=1) + + # Título sección + lbl_paths = ctk.CTkLabel(self.frame_paths, text="Rutas y Archivos", font=self.font_section) + lbl_paths.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(5, 2)) + + # Carpeta Raíz + self.root_label = ctk.CTkLabel(self.frame_paths, text="Carpeta Raíz:", font=self.font_ui) + self.root_label.grid(row=1, column=0, padx=15, pady=2, sticky="w") + + self.root_entry = ctk.CTkEntry(self.frame_paths, placeholder_text="Selecciona la carpeta con los documentos...", font=self.font_ui) + self.root_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew") + + self.root_button = ctk.CTkButton(self.frame_paths, text="Explorar...", font=self.font_ui, width=100) + self.root_button.grid(row=1, column=2, padx=15, pady=2) + + # CSV Salida + self.csv_label = ctk.CTkLabel(self.frame_paths, text="Archivo CSV:", font=self.font_ui) + self.csv_label.grid(row=2, column=0, padx=15, pady=2, sticky="w") + + self.csv_entry = ctk.CTkEntry(self.frame_paths, placeholder_text="Ruta para guardar el reporte CSV...", font=self.font_ui) + self.csv_entry.grid(row=2, column=1, padx=5, pady=2, sticky="ew") + + self.csv_button = ctk.CTkButton(self.frame_paths, text="Guardar en...", font=self.font_ui, width=100, fg_color="transparent", border_width=1) + self.csv_button.grid(row=2, column=2, padx=15, pady=2) + + # Selector de Modo (Radicados vs General) + self.mode_var = ctk.StringVar(value="radicados") + + lbl_mode = ctk.CTkLabel(self.frame_paths, text="Estructura:", font=self.font_ui) + lbl_mode.grid(row=3, column=0, padx=15, pady=2, sticky="w") + + self.frame_radios_main = ctk.CTkFrame(self.frame_paths, fg_color="transparent") + self.frame_radios_main.grid(row=3, column=1, columnspan=2, sticky="w", padx=5, pady=2) + + self.radio_radicados_main = ctk.CTkRadioButton( + self.frame_radios_main, + text="Por Radicados (Subcarpetas)", + variable=self.mode_var, + value="radicados", + font=self.font_ui, + command=self._update_ui_for_mode + ) + self.radio_radicados_main.pack(side="left", padx=(0, 20)) + + self.radio_general_main = ctk.CTkRadioButton( + self.frame_radios_main, + text="Carpeta General (Archivos Planos)", + variable=self.mode_var, + value="general", + font=self.font_ui, + command=self._update_ui_for_mode + ) + self.radio_general_main.pack(side="left") + + # --- Sección Configuración --- + self.frame_config = ctk.CTkFrame(self.scroll_container) + self.frame_config.grid(row=1, column=0, sticky="ew", padx=10, pady=2) + self.frame_config.grid_columnconfigure(0, weight=1) + + # Keywords oculto (legacy) + self.kw_label = ctk.CTkLabel(self.frame_config, text="Keywords:") + self.kw_entry = ctk.CTkEntry(self.frame_config) + self.kw_entry.insert(0, "Factura_O_Documento_Equivalente,factura") + + # Tipos de Documento + self.doc_types_container = ctk.CTkFrame(self.frame_config, fg_color="transparent") + self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2)) + self.doc_types_container.grid_columnconfigure(0, weight=1) + + self.doc_types_header = ctk.CTkFrame(self.doc_types_container, fg_color="transparent") + self.doc_types_header.grid(row=0, column=0, sticky="ew", pady=(0, 2)) + self.doc_types_header.grid_columnconfigure(0, weight=1) + + self.doc_types_label = ctk.CTkLabel(self.doc_types_header, text="Definición de Tipos de Documento", font=self.font_section) + self.doc_types_label.grid(row=0, column=0, sticky="w") + + self.add_doc_type_btn = ctk.CTkButton(self.doc_types_header, text="+ Añadir Tipo", font=self.font_ui, width=120, height=28) + self.add_doc_type_btn.grid(row=0, column=1, sticky="e") + + # Lista de Tipos: Usamos CTkFrame normal para que crezca y active el scroll del contenedor principal + self.doc_types_list = ctk.CTkFrame(self.doc_types_container, fg_color="transparent") + self.doc_types_list.grid(row=1, column=0, sticky="ew", pady=(2, 0)) + self.doc_types_list.grid_columnconfigure(0, weight=1) + + + self._doc_type_rows = [] + + def _on_add_row(): + self._add_doc_type_row() + self.add_doc_type_btn.configure(command=_on_add_row) + + # Fila por defecto + self._add_doc_type_row(name="Factura", keyword="Factura_O_Documento_Equivalente") + + # Configuración Avanzada (TIFF Range) + self.frame_advanced = ctk.CTkFrame(self.frame_config, fg_color="transparent") + self.frame_advanced.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 2)) + + self.tiff_start_label = ctk.CTkLabel(self.frame_advanced, text="Pág. Inicio TIFF:", font=self.font_ui) + self.tiff_start_entry = ctk.CTkEntry(self.frame_advanced, width=60) + self.tiff_start_entry.insert(0, "0") + + self.tiff_end_label = ctk.CTkLabel(self.frame_advanced, text="Pág. Fin TIFF:", font=self.font_ui) + self.tiff_end_entry = ctk.CTkEntry(self.frame_advanced, width=60) + self.tiff_end_entry.insert(0, "0") + + # Ocultar inicialmente + self.tiff_start_label.grid_forget() + self.tiff_start_entry.grid_forget() + self.tiff_end_label.grid_forget() + self.tiff_end_entry.grid_forget() + + # Campos legacy ocultos + self.fields_entry = ctk.CTkEntry(self.tab_main) + self.fields_label = ctk.CTkLabel(self.tab_main, text="") + + # --- Botón de Acción Principal --- + self.process_button = ctk.CTkButton( + self.scroll_container, + text="INICIAR PROCESAMIENTO", + font=ctk.CTkFont(size=16, weight="bold"), + height=35, + fg_color=("#106BA3", "#106BA3"), + hover_color=("#0D5582", "#0D5582") + ) + self.process_button.grid(row=2, column=0, padx=15, pady=(10, 15), sticky="ew") + + # --- Área de Logs / Salida (Integrada en Scroll) --- + self.log_frame = ctk.CTkFrame(self.scroll_container) + self.log_frame.grid(row=3, column=0, sticky="ew", padx=10, pady=(0, 15)) + self.log_frame.grid_columnconfigure(0, weight=1) + + lbl_logs = ctk.CTkLabel(self.log_frame, text="Registro de Actividad:", font=self.font_ui, text_color=("gray50", "gray70")) + lbl_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0)) + + self.output_text = ctk.CTkTextbox(self.log_frame, font=self.font_mono, activate_scrollbars=True, height=150) + self.output_text.grid(row=1, column=0, padx=5, pady=5, sticky="ew") + + # ========================================================================= + # --- TAB: RESULTADOS --- + # ========================================================================= + self.tab_results.grid_columnconfigure(0, weight=1) + self.tab_results.grid_rowconfigure(0, weight=1) + + self.tabs_res = ctk.CTkTabview(self.tab_results) + self.tabs_res.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) + + self.tab_res_extractor = self.tabs_res.add("Extractor (Próximamente)") + self.tab_res_preclass = self.tabs_res.add("Preclasificación") + + # --- Sub-Tab: Extractor --- + lbl_const = ctk.CTkLabel(self.tab_res_extractor, text="En construcción...", font=ctk.CTkFont(size=20)) + lbl_const.place(relx=0.5, rely=0.5, anchor="center") + + # --- Sub-Tab: Preclasificación --- + self.tab_res_preclass.grid_columnconfigure(0, weight=1) + self.tab_res_preclass.grid_rowconfigure(1, weight=1) + + # 1. Filtros + self.frame_res_filters = ctk.CTkFrame(self.tab_res_preclass) + self.frame_res_filters.grid(row=0, column=0, sticky="ew", padx=10, pady=5) + + ctk.CTkLabel(self.frame_res_filters, text="Run ID:").grid(row=0, column=0, padx=5, pady=5) + self.combo_res_run = ctk.CTkComboBox(self.frame_res_filters, values=["Todos"], width=150) + self.combo_res_run.grid(row=0, column=1, padx=5, pady=5) + + ctk.CTkLabel(self.frame_res_filters, text="Estado:").grid(row=0, column=2, padx=5, pady=5) + self.combo_res_status = ctk.CTkComboBox(self.frame_res_filters, values=["Todos", "MATCH", "NO_MATCH", "ERROR", "NO_FILES"], width=120) + self.combo_res_status.grid(row=0, column=3, padx=5, pady=5) + + self.entry_res_search = ctk.CTkEntry(self.frame_res_filters, placeholder_text="Buscar radicado/archivo...", width=200) + self.entry_res_search.grid(row=0, column=4, padx=5, pady=5) + + self.btn_res_search = ctk.CTkButton(self.frame_res_filters, text="Buscar", width=100) + self.btn_res_search.grid(row=0, column=5, padx=10, pady=5) + + # 2. Tabla (Treeview) + self.frame_res_table = ctk.CTkFrame(self.tab_res_preclass, fg_color="transparent") + self.frame_res_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=5) + self.frame_res_table.grid_columnconfigure(0, weight=1) + self.frame_res_table.grid_rowconfigure(0, weight=3) # Peso para la tabla principal + self.frame_res_table.grid_rowconfigure(2, weight=2) # Peso para la tabla detalle + + import tkinter.ttk as ttk + # Estilo básico para treeview + style = ttk.Style() + style.theme_use("clam") + style.configure("Treeview", rowheight=25) + + # --- Tabla Principal --- + cols = ("ID", "Run", "Fecha", "Radicado", "Estado", "Archivo") + self.tree_results = ttk.Treeview(self.frame_res_table, columns=cols, show="headings", selectmode="browse") + + for c in cols: + self.tree_results.heading(c, text=c) + self.tree_results.column(c, width=100) + self.tree_results.column("Archivo", width=300) + self.tree_results.column("ID", width=50) + self.tree_results.column("Run", width=50) + + # Scrollbars principal + sb_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_results.yview) + sb_x = ttk.Scrollbar(self.frame_res_table, orient="horizontal", command=self.tree_results.xview) + self.tree_results.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set) + + self.tree_results.grid(row=0, column=0, sticky="nsew") + sb_y.grid(row=0, column=1, sticky="ns") + sb_x.grid(row=1, column=0, sticky="ew") + + # Separador / Título Detalle + lbl_detail = ctk.CTkLabel(self.frame_res_table, text="Detalle de Matches (Seleccionar fila arriba)", font=ctk.CTkFont(size=12, weight="bold")) + lbl_detail.grid(row=1, column=0, sticky="w", pady=(10, 5)) + + # --- Tabla Detalle --- + cols_detail = ("Key", "Valor Encontrado", "Existe", "Valor Esperado") + self.tree_matches = ttk.Treeview(self.frame_res_table, columns=cols_detail, show="headings", selectmode="browse", height=5) + + for c in cols_detail: + self.tree_matches.heading(c, text=c) + self.tree_matches.column(c, width=150) + + sb_detail_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_matches.yview) + self.tree_matches.configure(yscrollcommand=sb_detail_y.set) + + self.tree_matches.grid(row=2, column=0, sticky="nsew") + sb_detail_y.grid(row=2, column=1, sticky="ns") + + # 3. Acciones + self.frame_res_actions = ctk.CTkFrame(self.tab_res_preclass) + self.frame_res_actions.grid(row=2, column=0, sticky="ew", padx=10, pady=10) + + self.btn_res_export = ctk.CTkButton(self.frame_res_actions, text="Exportar Vista a Excel", fg_color="green") + self.btn_res_export.pack(side="right", padx=10, pady=5) + + self.btn_res_organize = ctk.CTkButton(self.frame_res_actions, text="Organizar Carpetas (Run)", fg_color="#D35400", hover_color="#A04000") + self.btn_res_organize.pack(side="right", padx=10, pady=5) + + self.btn_res_refresh = ctk.CTkButton(self.frame_res_actions, text="Refrescar Runs") + self.btn_res_refresh.pack(side="left", padx=10, pady=5) + + + # ========================================================================= + # --- TAB: CONFIGURACIÓN --- + # ========================================================================= + self.tab_settings.grid_columnconfigure(0, weight=1) + self.tab_settings.grid_rowconfigure(0, weight=1) + + self.settings_container = ctk.CTkFrame(self.tab_settings, fg_color="transparent") + self.settings_container.grid(row=0, column=0, sticky="nsew", padx=20, pady=20) + self.settings_container.grid_columnconfigure(0, weight=1) + + # --- Sección API / Modelo --- + self.frame_api = ctk.CTkFrame(self.settings_container) + self.frame_api.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + self.frame_api.grid_columnconfigure(1, weight=1) + + lbl_api_title = ctk.CTkLabel(self.frame_api, text="Configuración del Modelo LLM", font=self.font_section) + lbl_api_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10) + + self.lbl_model = ctk.CTkLabel(self.frame_api, text="Modelo (Ollama):", font=self.font_ui) + self.lbl_model.grid(row=1, column=0, padx=15, pady=10, sticky="w") + + self.combo_model = ctk.CTkComboBox( + self.frame_api, + values=["5.1", "4.1", "4.1 mini", "4.1 nano"], + width=250, + font=self.font_ui + ) + self.combo_model.grid(row=1, column=1, padx=15, pady=10, sticky="w") + self.combo_model.set("4.1") + + # Texto de ayuda para modelos + lbl_help = ctk.CTkLabel( + self.frame_api, + text="ℹ 4.1 mini: Muy rápido (recomendado)\nℹ 4.1: Estándar (balanceado)\nℹ 5.1: Lento pero mayor razonamiento", + font=ctk.CTkFont(size=12, slant="italic"), + text_color="gray60", + justify="left" + ) + lbl_help.grid(row=2, column=1, padx=15, pady=(0, 10), sticky="w") + + # --- Sección Procesamiento --- + self.frame_proc = ctk.CTkFrame(self.settings_container) + self.frame_proc.grid(row=1, column=0, sticky="ew", padx=5, pady=5) + self.frame_proc.grid_columnconfigure(1, weight=1) + + lbl_proc_title = ctk.CTkLabel(self.frame_proc, text="Procesamiento y Rendimiento", font=self.font_section) + lbl_proc_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10) + + lbl_conc = ctk.CTkLabel(self.frame_proc, text="Límite de Concurrencia:", font=self.font_ui) + lbl_conc.grid(row=1, column=0, padx=15, pady=10, sticky="w") + + self.concurrency_var = ctk.StringVar(value="20") + self.concurrency_entry = ctk.CTkEntry(self.frame_proc, textvariable=self.concurrency_var, width=100, font=self.font_ui) + self.concurrency_entry.grid(row=1, column=1, padx=15, pady=10, sticky="w") + + lbl_conc_warn = ctk.CTkLabel( + self.frame_proc, + text="⚠️ Advertencia: Entre mayor el número, mayor la probabilidad de Rate Limits (429).", + font=ctk.CTkFont(size=12, slant="italic"), + text_color="orange" + ) + lbl_conc_warn.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 10), sticky="w") + + # Botón Guardar (Fuera de los frames específicos, al final) + self.btn_save_settings = ctk.CTkButton( + self.settings_container, + text="Aplicar Configuración", + font=self.font_ui, + height=35 + ) + self.btn_save_settings.grid(row=2, column=0, padx=15, pady=15, sticky="ew") + + # ========================================================================= + # --- TAB: PRECLASIFICACIÓN --- + # ========================================================================= + self.tab_preclass.grid_columnconfigure(0, weight=1) + self.tab_preclass.grid_rowconfigure(0, weight=1) + + self.preclass_container = ctk.CTkScrollableFrame(self.tab_preclass, fg_color="transparent") + self.preclass_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + self.preclass_container.grid_columnconfigure(0, weight=1) + # En un ScrollableFrame, grid_rowconfigure con weight=1 hace que esa fila intente ocupar + # el espacio vertical disponible en el viewport. + # Row 3 es patterns, Row 5 es logs. + self.preclass_container.grid_rowconfigure(3, weight=1) + self.preclass_container.grid_rowconfigure(5, weight=1) + + # --- Sección: Carpeta Raíz --- + self.preclass_frame_root = ctk.CTkFrame(self.preclass_container) + self.preclass_frame_root.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 10)) + self.preclass_frame_root.grid_columnconfigure(1, weight=1) + + lbl_pre_root_title = ctk.CTkLabel(self.preclass_frame_root, text="Carpeta Raíz (Preclasificación)", font=self.font_section) + lbl_pre_root_title.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(10, 5)) + + self.preclass_root_label = ctk.CTkLabel(self.preclass_frame_root, text="Carpeta Raíz:", font=self.font_ui) + self.preclass_root_label.grid(row=1, column=0, padx=15, pady=(0, 10), sticky="w") + + self.preclass_root_entry = ctk.CTkEntry( + self.preclass_frame_root, + placeholder_text="Selecciona la carpeta raíz para preclasificar...", + font=self.font_ui + ) + self.preclass_root_entry.grid(row=1, column=1, padx=5, pady=(0, 10), sticky="ew") + + self.preclass_root_button = ctk.CTkButton(self.preclass_frame_root, text="Explorar...", font=self.font_ui, width=100) + self.preclass_root_button.grid(row=1, column=2, padx=15, pady=(0, 10)) + + # Selector de Modo (Radicados vs General) + self.preclass_mode_var = ctk.StringVar(value="radicados") + + lbl_mode = ctk.CTkLabel(self.preclass_frame_root, text="Estructura:", font=self.font_ui) + lbl_mode.grid(row=2, column=0, padx=15, pady=(0, 10), sticky="w") + + self.frame_radios = ctk.CTkFrame(self.preclass_frame_root, fg_color="transparent") + self.frame_radios.grid(row=2, column=1, columnspan=2, sticky="w", padx=5, pady=(0, 10)) + + self.radio_radicados = ctk.CTkRadioButton( + self.frame_radios, + text="Por Radicados (Subcarpetas)", + variable=self.preclass_mode_var, + value="radicados", + font=self.font_ui + ) + self.radio_radicados.pack(side="left", padx=(0, 20)) + + self.radio_general = ctk.CTkRadioButton( + self.frame_radios, + text="Carpeta General (Archivos Planos)", + variable=self.preclass_mode_var, + value="general", + font=self.font_ui + ) + self.radio_general.pack(side="left") + + # --- Sección: Opciones de Rango (Preclasificación) --- + self.preclass_frame_options = ctk.CTkFrame(self.preclass_container) + self.preclass_frame_options.grid(row=1, column=0, sticky="ew", padx=5, pady=(0, 10)) + self.preclass_frame_options.grid_columnconfigure(1, weight=1) + + self.preclass_range_var = ctk.BooleanVar(value=False) + self.preclass_range_cb = ctk.CTkCheckBox( + self.preclass_frame_options, + text="Habilitar rangos de página", + variable=self.preclass_range_var, + font=self.font_ui, + command=self._on_toggle_preclass_range + ) + self.preclass_range_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w") + + self.preclass_range_inputs_frame = ctk.CTkFrame(self.preclass_frame_options, fg_color="transparent") + # Se mostrará solo si se habilita + + lbl_desde = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Desde:", font=self.font_ui) + lbl_desde.grid(row=0, column=0, padx=(10, 5), pady=5) + self.preclass_range_start_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.font_ui) + self.preclass_range_start_entry.insert(0, "1") + self.preclass_range_start_entry.grid(row=0, column=1, padx=5, pady=5) + + lbl_hasta = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Hasta:", font=self.font_ui) + lbl_hasta.grid(row=0, column=2, padx=(10, 5), pady=5) + self.preclass_range_end_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.font_ui) + self.preclass_range_end_entry.insert(0, "1") + self.preclass_range_end_entry.grid(row=0, column=3, padx=5, pady=5) + + # --- Sección: Opciones de Archivo Objetivo (Preclasificación) --- + self.preclass_frame_target = ctk.CTkFrame(self.preclass_container) + self.preclass_frame_target.grid(row=2, column=0, sticky="ew", padx=5, pady=(0, 10)) + self.preclass_frame_target.grid_columnconfigure(0, weight=1) + + self.preclass_target_var = ctk.BooleanVar(value=False) + self.preclass_target_cb = ctk.CTkCheckBox( + self.preclass_frame_target, + text="Habilitar archivo objetivo", + variable=self.preclass_target_var, + font=self.font_ui, + command=self._on_toggle_preclass_target + ) + self.preclass_target_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w") + + self.preclass_target_inputs_frame = ctk.CTkFrame(self.preclass_frame_target, fg_color="transparent") + # Se mostrará solo si se habilita (en row 1 para que aparezca ABAJO del checkbox) + + lbl_target_def = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Def. Tipo Doc:", font=self.font_ui) + lbl_target_def.grid(row=0, column=0, padx=(15, 5), pady=5, sticky="w") + self.preclass_target_def_entry = ctk.CTkEntry(self.preclass_target_inputs_frame, width=250, font=self.font_ui, placeholder_text="Ej: Factura de Venta") + self.preclass_target_def_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + lbl_target_pattern = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Patrón:", font=self.font_ui) + lbl_target_pattern.grid(row=0, column=2, padx=(20, 5), pady=5, sticky="w") + self.preclass_target_pattern_entry = ctk.CTkEntry(self.preclass_target_inputs_frame, width=200, font=self.font_ui, placeholder_text="Ej: *_FAC_*.pdf") + self.preclass_target_pattern_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w") + + # --- Sección: Patrones --- + self.preclass_frame_patterns = ctk.CTkFrame(self.preclass_container) + self.preclass_frame_patterns.grid(row=3, column=0, sticky="nsew", padx=5, pady=5) + self.preclass_frame_patterns.grid_columnconfigure(0, weight=1) + + self.preclass_patterns_header = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent") + self.preclass_patterns_header.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 2)) + self.preclass_patterns_header.grid_columnconfigure(0, weight=1) + + self.preclass_patterns_label = ctk.CTkLabel(self.preclass_patterns_header, text="Patrones", font=self.font_section) + self.preclass_patterns_label.grid(row=0, column=0, sticky="w") + + self.add_preclass_pattern_btn = ctk.CTkButton(self.preclass_patterns_header, text="+ Añadir Patrón", font=self.font_ui, width=140, height=28) + self.add_preclass_pattern_btn.grid(row=0, column=1, sticky="e") + + self.preclass_patterns_hint = ctk.CTkLabel( + self.preclass_frame_patterns, + text="Agrega patrones de texto. Cada patrón puede ser solo 'key' o 'key + value'.", + font=ctk.CTkFont(size=12, slant="italic"), + text_color="gray60", + justify="left" + ) + self.preclass_patterns_hint.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 5)) + + self.preclass_patterns_list = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent") + self.preclass_patterns_list.grid(row=2, column=0, sticky="ew", padx=10, pady=(2, 10)) + self.preclass_patterns_list.grid_columnconfigure(0, weight=1) + + self._preclass_pattern_rows = [] + + def _on_add_preclass_row(): + self._add_preclass_pattern_row() + self.add_preclass_pattern_btn.configure(command=_on_add_preclass_row) + + # Fila por defecto (vacía) + self._add_preclass_pattern_row() + + # --- Sección: Botón de Ejecución y Logs --- + self.preclass_action_frame = ctk.CTkFrame(self.preclass_container, fg_color="transparent") + self.preclass_action_frame.grid(row=4, column=0, sticky="ew", padx=5, pady=10) + self.preclass_action_frame.grid_columnconfigure(0, weight=1) + + self.preclass_run_btn = ctk.CTkButton( + self.preclass_action_frame, + text="EJECUTAR PRECLASIFICACIÓN", + font=ctk.CTkFont(size=16, weight="bold"), + height=40, + fg_color=("#2E86C1", "#2E86C1"), + hover_color=("#21618C", "#21618C") + ) + self.preclass_run_btn.pack(side="left", fill="x", expand=True, padx=10) + + self.preclass_export_btn = ctk.CTkButton( + self.preclass_action_frame, + text="Exportar Excel", + font=self.font_ui, + height=40, + state="disabled", + fg_color=("#27AE60", "#27AE60"), + hover_color=("#1E8449", "#1E8449") + ) + self.preclass_export_btn.pack(side="left", padx=(0, 10)) + + self.preclass_organize_btn = ctk.CTkButton( + self.preclass_action_frame, + text="Organizar Carpetas", + font=self.font_ui, + height=40, + state="disabled", + fg_color=("#D35400", "#D35400"), + hover_color=("#A04000", "#A04000") + ) + self.preclass_organize_btn.pack(side="left", padx=(0, 10)) + + self.preclass_log_frame = ctk.CTkFrame(self.preclass_container) + self.preclass_log_frame.grid(row=5, column=0, sticky="nsew", padx=5, pady=(0, 10)) + self.preclass_log_frame.grid_columnconfigure(0, weight=1) + self.preclass_log_frame.grid_rowconfigure(1, weight=1) + + # Ajustar pesos del contenedor principal para que el log expanda + self.preclass_container.grid_rowconfigure(5, weight=1) + # Quitar peso a la fila de patrones si queremos que el log ocupe más espacio, + # o compartir. Actualmente row 3 (patrones) tiene weight. + # Vamos a dejar patterns con weight 1 y logs con weight 1 para compartir espacio. + self.preclass_container.grid_rowconfigure(3, weight=1) + + lbl_pre_logs = ctk.CTkLabel(self.preclass_log_frame, text="Resultados:", font=self.font_ui, text_color=("gray50", "gray70")) + lbl_pre_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0)) + + self.preclass_output = ctk.CTkTextbox(self.preclass_log_frame, font=self.font_mono, activate_scrollbars=True) + self.preclass_output.grid(row=1, column=0, padx=5, pady=5, sticky="nsew") + + + # ========================================================================= + # --- TAB: DASHBOARD & MÉTRICAS --- + # ========================================================================= + self.tab_usage.grid_columnconfigure(0, weight=1) + self.tab_usage.grid_rowconfigure(1, weight=1) + self.tab_usage.grid_rowconfigure(3, weight=1) + + # Panel de KPIs (Top) + self.kpi_frame = ctk.CTkFrame(self.tab_usage) + self.kpi_frame.grid(row=0, column=0, sticky="ew", padx=15, pady=15) + self.kpi_frame.grid_columnconfigure((0, 1, 2), weight=1) + + # Helper para crear cards KPI + def create_kpi_card(parent, title, row, col): + card = ctk.CTkFrame(parent) + card.grid(row=row, column=col, sticky="nsew", padx=5, pady=5) + lbl_title = ctk.CTkLabel(card, text=title, font=ctk.CTkFont(size=14, weight="bold")) + lbl_title.pack(pady=(10, 5), padx=10, anchor="w") + lbl_content = ctk.CTkLabel(card, text="...", font=self.font_mono, justify="left") + lbl_content.pack(pady=(0, 10), padx=10, anchor="w") + return lbl_content + + self.kpi_overall = create_kpi_card(self.kpi_frame, "Total General", 0, 0) + self.kpi_extract = create_kpi_card(self.kpi_frame, "Fase: Extracción", 0, 1) + self.kpi_normalize = create_kpi_card(self.kpi_frame, "Fase: Normalización", 0, 2) + + self.kpi_overall.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00") + self.kpi_extract.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00") + self.kpi_normalize.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00") + + self.usage_totals = ctk.CTkLabel(self.tab_usage, text="") # Legacy label hidden + + # Panel Central (Gráficos) + self.charts_frame = ctk.CTkFrame(self.tab_usage) + self.charts_frame.grid(row=1, column=0, sticky="nsew", padx=15, pady=5) + self.charts_frame.grid_columnconfigure((0, 1), weight=1) + self.charts_frame.grid_rowconfigure(0, weight=1) + + # Gráfico Línea + self._cost_line_points_x = [] + self._cost_line_points_y = [] + + if HAS_MPL: + self.fig_line = Figure(figsize=(4, 3), dpi=100) + self.fig_line.patch.set_facecolor('#f0f0f0') # Match light theme approx or transparent + self.ax_line = self.fig_line.add_subplot(111) + self.ax_line.set_title("Costo Acumulado (USD)", fontsize=10) + self.ax_line.tick_params(labelsize=8) + self._line_plot, = self.ax_line.plot([], [], marker="o", color="#1f77b4") + + self.canvas_line = FigureCanvasTkAgg(self.fig_line, master=self.charts_frame) + self.canvas_line.get_tk_widget().grid(row=0, column=0, padx=10, pady=10, sticky="nsew") + else: + ctk.CTkLabel(self.charts_frame, text="Gráficos no disponibles").grid(row=0, column=0) + + # Gráfico Barras + if HAS_MPL: + self.fig_bars = Figure(figsize=(4, 3), dpi=100) + self.fig_bars.patch.set_facecolor('#f0f0f0') + self.ax_bars = self.fig_bars.add_subplot(111) + self.ax_bars.set_title("Consumo de Tokens", fontsize=10) + self.ax_bars.tick_params(labelsize=8) + + self.canvas_bars = FigureCanvasTkAgg(self.fig_bars, master=self.charts_frame) + self.canvas_bars.get_tk_widget().grid(row=0, column=1, padx=10, pady=10, sticky="nsew") + + # Panel Comparativo de Modelos + self.comparison_frame = ctk.CTkFrame(self.tab_usage) + self.comparison_frame.grid(row=2, column=0, sticky="ew", padx=15, pady=5) + self.comparison_frame.grid_columnconfigure(0, weight=1) + + lbl_comp_title = ctk.CTkLabel(self.comparison_frame, text="Tarifas de Modelos (USD por 1M tokens)", font=ctk.CTkFont(size=14, weight="bold")) + lbl_comp_title.grid(row=0, column=0, sticky="w", padx=10, pady=5) + + # Tabla de comparación + from tkinter import ttk + self.tree_comparison = ttk.Treeview( + self.comparison_frame, + columns=("model", "input", "cached", "output"), + show="headings", + height=4 + ) + self.tree_comparison.heading("model", text="Modelo") + self.tree_comparison.heading("input", text="Input / 1M") + self.tree_comparison.heading("cached", text="Cached / 1M") + self.tree_comparison.heading("output", text="Output / 1M") + + self.tree_comparison.column("model", width=150, anchor="w") + self.tree_comparison.column("input", width=100, anchor="e") + self.tree_comparison.column("cached", width=100, anchor="e") + self.tree_comparison.column("output", width=100, anchor="e") + + self.tree_comparison.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10)) + + # Panel Inferior (Detalles Texto) + self.usage_text = ctk.CTkTextbox(self.tab_usage, height=150, font=self.font_mono) + self.usage_text.grid(row=3, column=0, sticky="ew", padx=15, pady=15) + + + # --------------------------------------------------------------------------- + # MÉTODOS PÚBLICOS (API COMPATIBLE) + # --------------------------------------------------------------------------- + + def set_on_select_root(self, callback): + self.root_button.configure(command=callback) + + def set_on_select_preclass_root(self, callback): + self.preclass_root_button.configure(command=callback) + + def set_on_process(self, callback): + self.process_button.configure(command=callback) + + def set_on_select_csv(self, callback): + self.csv_button.configure(command=callback) + + def set_on_save_settings(self, callback): + self.btn_save_settings.configure(command=callback) + + def set_on_run_preclassification(self, callback): + self.preclass_run_btn.configure(command=callback) + + def set_on_export_excel(self, callback): + self.preclass_export_btn.configure(command=callback) + + def set_on_organize_files(self, callback): + self.preclass_organize_btn.configure(command=callback) + + def enable_preclass_actions(self, enabled: bool): + state = "normal" if enabled else "disabled" + self.preclass_export_btn.configure(state=state) + self.preclass_organize_btn.configure(state=state) + + # --- Métodos Resultados --- + def set_on_res_search(self, callback): + self.btn_res_search.configure(command=callback) + + def set_on_res_export(self, callback): + self.btn_res_export.configure(command=callback) + + def set_on_res_refresh(self, callback): + self.btn_res_refresh.configure(command=callback) + + def set_on_res_organize(self, callback): + self.btn_res_organize.configure(command=callback) + + def set_on_res_select(self, callback): + # Bind TreeviewSelect event + self.tree_results.bind("<>", callback) + + def update_res_matches_table(self, rows): + # Limpiar + for item in self.tree_matches.get_children(): + self.tree_matches.delete(item) + # Llenar + for r in rows: + # r: key, expected_value, found_value, exists_bool + exists_str = "✅" if r.get("exists_bool") else "❌" + vals = (r.get("key"), r.get("found_value"), exists_str, r.get("expected_value") or "-") + self.tree_matches.insert("", "end", values=vals) + + def update_res_runs_combo(self, runs: list): + vals = ["Todos"] + [str(r) for r in runs] + self.combo_res_run.configure(values=vals) + self.combo_res_run.set("Todos") + + def set_res_run(self, run_id: int | str): + try: + self.combo_res_run.set(str(run_id)) + except Exception: + pass + + def get_res_filters(self): + return { + "run_id": self.combo_res_run.get(), + "status": self.combo_res_status.get(), + "text": self.entry_res_search.get() + } + + def update_res_table(self, rows): + # Limpiar + for item in self.tree_results.get_children(): + self.tree_results.delete(item) + # Llenar + for r in rows: + # r dict: id, run_id, radicado, file_path, status, timestamp + vals = (r.get("id"), r.get("run_id"), r.get("timestamp"), r.get("radicado"), r.get("status"), os.path.basename(r.get("file_path") or "")) + self.tree_results.insert("", "end", values=vals) + + def append_preclass_output(self, text: str) -> None: + self.preclass_output.insert("end", text + "\n") + self.preclass_output.see("end") + + def append_preclass_output_safe(self, text: str) -> None: + self.after(0, lambda: self.append_preclass_output(text)) + + def get_selected_model(self) -> str: + return self.combo_model.get() + + def set_selected_model(self, model: str) -> None: + self.combo_model.set(model) + + def get_concurrency_limit(self) -> int: + try: + return int(self.concurrency_var.get()) + except Exception: + return 20 + + def set_concurrency_limit(self, limit: int) -> None: + self.concurrency_var.set(str(limit)) + + def _update_ui_for_mode(self): + """Actualiza la visibilidad de componentes según el modo seleccionado.""" + mode = self.get_process_mode() + if mode == "general": + # Ocultar Tipos de Documento + self.doc_types_container.grid_forget() + # Mostrar Keywords (opcional, para filtrar) + self.kw_label.grid(row=1, column=0, padx=15, pady=2, sticky="w") + self.kw_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew") + + # Si tiene el valor por defecto de facturas, limpiarlo para sugerir "todos" + curr = self.kw_entry.get() + if "Factura_O_Documento_Equivalente" in curr: + self.kw_entry.delete(0, "end") + + else: + # Mostrar Tipos de Documento + self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2)) + # Ocultar Keywords + self.kw_label.grid_forget() + self.kw_entry.grid_forget() + + def get_root_path(self) -> str: + return self.root_entry.get().strip() + + def get_process_mode(self) -> str: + """Devuelve el modo de procesamiento: 'radicados' o 'general'.""" + try: + v = (self.mode_var.get() or "").strip().lower() + return v if v in ("radicados", "general") else "radicados" + except Exception: + return "radicados" + + def get_preclass_root_path(self) -> str: + return self.preclass_root_entry.get().strip() + + def get_preclass_mode(self) -> str: + """Devuelve el modo de preclasificación: 'radicados' o 'general'.""" + try: + v = (self.preclass_mode_var.get() or "").strip().lower() + return v if v in ("radicados", "general") else "radicados" + except Exception: + return "radicados" + + def get_preclass_range(self) -> tuple[bool, int, int]: + """Devuelve (habilitado, desde, hasta) para preclasificación.""" + enabled = self.preclass_range_var.get() + try: + start = int(self.preclass_range_start_entry.get().strip() or "1") + end = int(self.preclass_range_end_entry.get().strip() or "1") + except Exception: + start, end = 1, 1 + return enabled, start, end + + def get_preclass_target_info(self) -> dict: + """Devuelve la configuración del archivo objetivo.""" + return { + "enabled": self.preclass_target_var.get(), + "definition": self.preclass_target_def_entry.get().strip(), + "pattern": self.preclass_target_pattern_entry.get().strip() + } + + def _on_toggle_preclass_range(self): + """Muestra u oculta los inputs de rango según el checkbox.""" + if self.preclass_range_var.get(): + self.preclass_range_inputs_frame.grid(row=0, column=1, padx=10, pady=0, sticky="w") + else: + self.preclass_range_inputs_frame.grid_forget() + + def _on_toggle_preclass_target(self): + """Muestra u oculta los inputs de archivo objetivo según el checkbox.""" + if self.preclass_target_var.get(): + self.preclass_target_inputs_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="w") + else: + self.preclass_target_inputs_frame.grid_forget() + + def get_keywords(self) -> str: + return self.kw_entry.get().strip() + + def get_fields(self) -> str: + return self.fields_entry.get().strip() + + def get_csv_path(self) -> str: + return self.csv_entry.get().strip() + + def show_tiff_range_controls(self, show: bool) -> None: + if show: + self.tiff_start_label.grid(row=0, column=0, padx=5, pady=5) + self.tiff_start_entry.grid(row=0, column=1, padx=5, pady=5) + self.tiff_end_label.grid(row=0, column=2, padx=5, pady=5) + self.tiff_end_entry.grid(row=0, column=3, padx=5, pady=5) + else: + self.tiff_start_label.grid_forget() + self.tiff_start_entry.grid_forget() + self.tiff_end_label.grid_forget() + self.tiff_end_entry.grid_forget() + + def get_tiff_start_page(self) -> int: + try: + value = self.tiff_start_entry.get().strip() + return int(value) if value else 0 + except Exception: + return 0 + + def get_tiff_end_page(self) -> int: + try: + value = self.tiff_end_entry.get().strip() + return int(value) if value else 0 + except Exception: + return 0 + + def append_output(self, text: str) -> None: + self.output_text.insert("end", text + "\n") + self.output_text.see("end") + + def append_output_safe(self, text: str) -> None: + self.after(0, lambda: self.append_output(text)) + + def set_processing(self, processing: bool) -> None: + state = "disabled" if processing else "normal" + + self.process_button.configure(state=state, text="PROCESANDO..." if processing else "INICIAR PROCESAMIENTO") + if processing: + self.process_button.configure(fg_color="gray") + else: + self.process_button.configure(fg_color=("#106BA3", "#106BA3")) + + self.root_button.configure(state=state) + self.root_entry.configure(state=state) + self.csv_button.configure(state=state) + self.csv_entry.configure(state=state) + self.add_doc_type_btn.configure(state=state) + + for row in self._doc_type_rows: + row["name_entry"].configure(state=state) + row["kw_entry"].configure(state=state) + row["remove_btn"].configure(state=state) + + # --- TAB PRECLASIFICACIÓN --- + try: + self.preclass_root_button.configure(state=state) + self.preclass_root_entry.configure(state=state) + self.preclass_range_cb.configure(state=state) + self.preclass_range_start_entry.configure(state=state) + self.preclass_range_end_entry.configure(state=state) + self.preclass_target_cb.configure(state=state) + self.preclass_target_def_entry.configure(state=state) + self.preclass_target_pattern_entry.configure(state=state) + self.radio_radicados.configure(state=state) + self.radio_general.configure(state=state) + self.radio_radicados_main.configure(state=state) + self.radio_general_main.configure(state=state) + self.add_preclass_pattern_btn.configure(state=state) + self.preclass_run_btn.configure(state=state) + # No deshabilitamos los de exportación aquí para permitir exportar mientras se procesa otro (opcional), + # o mejor sí bloquear todo. + if processing: + self.preclass_export_btn.configure(state="disabled") + self.preclass_organize_btn.configure(state="disabled") + + for row in self._preclass_pattern_rows: + row["key_entry"].configure(state=state) + row["mode_combo"].configure(state=state) + row["value_entry"].configure(state=state) + row["remove_btn"].configure(state=state) + except Exception: + pass + + # --- TAB USO --- + + def append_usage(self, text: str) -> None: + self.usage_text.insert("end", text + "\n") + self.usage_text.see("end") + + def append_usage_safe(self, text: str) -> None: + self.after(0, lambda: self.append_usage(text)) + + def clear_usage(self) -> None: + self.usage_text.delete("1.0", "end") + try: + for item in self.tree_comparison.get_children(): + self.tree_comparison.delete(item) + except Exception: + pass + # Reset KPIs + zero_txt = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000" + self.kpi_overall.configure(text=zero_txt) + self.kpi_extract.configure(text=zero_txt) + self.kpi_normalize.configure(text=zero_txt) + + # Reset Charts + if HAS_MPL: + self._cost_line_points_x.clear() + self._cost_line_points_y.clear() + self._line_plot.set_data([], []) + self.ax_line.relim() + self.ax_line.autoscale_view() + self.canvas_line.draw_idle() + + self.ax_bars.clear() + self.ax_bars.set_title("Consumo de Tokens") + self.canvas_bars.draw_idle() + + def set_usage_totals(self, **kwargs): + pass # Legacy, handled by KPIs now + + def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None: + def fmt(d): + return ( + f"input: {d.get('input',0)}\n" + f"cached: {d.get('cached',0)}\n" + f"output: {d.get('output',0)}\n" + f"USD: ${d.get('usd',0.0):.4f}" + ) + self.kpi_overall.configure(text=fmt(overall)) + self.kpi_extract.configure(text=fmt(extract_phase)) + self.kpi_normalize.configure(text=fmt(normalize_phase)) + + def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None: + if not HAS_MPL: return + try: + self._cost_line_points_x.append(doc_index) + self._cost_line_points_y.append(total_cost_usd) + self._line_plot.set_data(self._cost_line_points_x, self._cost_line_points_y) + self.ax_line.relim() + self.ax_line.autoscale_view() + self.canvas_line.draw_idle() + except Exception: + pass + + def update_token_bars(self, input_extract: int, cached_extract: int, output_extract: int, input_norm: int, cached_norm: int, output_norm: int) -> None: + if not HAS_MPL: return + try: + self.ax_bars.clear() + self.ax_bars.set_title("Tokens por Fase") + labels = ["Input", "Cached", "Output"] + extract_vals = [input_extract, cached_extract, output_extract] + norm_vals = [input_norm, cached_norm, output_norm] + + import numpy as np + x = np.arange(len(labels)) + width = 0.35 + + self.ax_bars.bar(x - width/2, extract_vals, width, label="Extracción", color="#1f77b4") + self.ax_bars.bar(x + width/2, norm_vals, width, label="Normalización", color="#ff7f0e") + + self.ax_bars.set_xticks(x, labels) + self.ax_bars.legend() + self.canvas_bars.draw_idle() + except Exception: + pass + + def update_comparison_table(self, comparisons: list[dict]) -> None: + """Actualiza la tabla de tarifas de modelos con los datos proporcionados.""" + try: + # Limpiar tabla + for item in self.tree_comparison.get_children(): + self.tree_comparison.delete(item) + + # Insertar filas + for c in comparisons: + self.tree_comparison.insert("", "end", values=( + c.get("model", "N/D"), + f"${c.get('input', 0.0):.2f}", + f"${c.get('cached_input', 0.0):.2f}", + f"${c.get('output', 0.0):.2f}" + )) + except Exception: + pass + + def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None: + try: + usd = (cost or {}).get('total_usd', 0.0) + self.append_usage(f"[{phase:<10}] {radicado:<20} | cost=${usd:.4f} | usage={usage}") + except Exception: + pass + + # --- HELPERS INTERNOS --- + + def _add_doc_type_row(self, name: str = "", keyword: str = "") -> None: + row = ctk.CTkFrame(self.doc_types_list, fg_color="transparent") + row.pack(fill="x", pady=2) + + grid_inner = ctk.CTkFrame(row, fg_color="transparent") + grid_inner.pack(fill="x", padx=5, pady=2) + grid_inner.grid_columnconfigure(0, weight=1) + grid_inner.grid_columnconfigure(1, weight=1) + + name_entry = ctk.CTkEntry(grid_inner, placeholder_text="Nombre Lógico", height=28, font=self.font_ui) + if name: name_entry.insert(0, name) + name_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew") + + kw_entry = ctk.CTkEntry(grid_inner, placeholder_text="Patrón / Keyword", height=28, font=self.font_ui) + if keyword: kw_entry.insert(0, keyword) + kw_entry.grid(row=0, column=1, padx=(0, 5), sticky="ew") + + def _remove(): + if row in [r["frame"] for r in self._doc_type_rows]: + # Buscar y eliminar de la lista interna + for i, r_data in enumerate(self._doc_type_rows): + if r_data["frame"] == row: + self._doc_type_rows.pop(i) + break + row.destroy() + + remove_btn = ctk.CTkButton(grid_inner, text="×", width=30, height=28, fg_color="#C0392B", hover_color="#A93226", command=_remove) + remove_btn.grid(row=0, column=2, sticky="e") + + self._doc_type_rows.append({ + "frame": row, + "name_entry": name_entry, + "kw_entry": kw_entry, + "remove_btn": remove_btn + }) + + def _add_preclass_pattern_row(self, key: str = "", mode: str = "key", value: str = "") -> None: + """Añade una fila editable para un patrón de preclasificación.""" + row = ctk.CTkFrame(self.preclass_patterns_list, fg_color="transparent") + row.pack(fill="x", pady=2) + + grid_inner = ctk.CTkFrame(row, fg_color="transparent") + grid_inner.pack(fill="x", padx=5, pady=2) + grid_inner.grid_columnconfigure(0, weight=1) + grid_inner.grid_columnconfigure(2, weight=1) + + key_entry = ctk.CTkEntry(grid_inner, placeholder_text="Patrón / Key", height=28, font=self.font_ui) + if key: + key_entry.insert(0, key) + key_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew") + + mode_combo = ctk.CTkComboBox( + grid_inner, + values=["key", "key+value"], + width=110, + font=self.font_ui + ) + mode_combo.grid(row=0, column=1, padx=(0, 5), sticky="w") + + value_entry = ctk.CTkEntry(grid_inner, placeholder_text="Value (si aplica)", height=28, font=self.font_ui) + if value: + value_entry.insert(0, value) + value_entry.grid(row=0, column=2, padx=(0, 5), sticky="ew") + + def _apply_mode(m: str): + m_norm = (m or "").strip().lower() + if m_norm in ("key+value", "key + value", "key_value", "key-value"): + value_entry.configure(state="normal", placeholder_text="Value (requerido)") + else: + # modo key: el value no aplica + value_entry.delete(0, "end") + value_entry.configure(state="disabled", placeholder_text="Value (no aplica)") + + def _on_mode_change(choice: str): + _apply_mode(choice) + + # set inicial + try: + mode_combo.set(mode if mode in ("key", "key+value") else "key") + except Exception: + mode_combo.set("key") + mode_combo.configure(command=_on_mode_change) + _apply_mode(mode_combo.get()) + + def _remove(): + try: + for i, r_data in enumerate(list(self._preclass_pattern_rows)): + if r_data.get("frame") == row: + self._preclass_pattern_rows.pop(i) + break + except Exception: + pass + row.destroy() + + remove_btn = ctk.CTkButton(grid_inner, text="×", width=30, height=28, fg_color="#C0392B", hover_color="#A93226", command=_remove) + remove_btn.grid(row=0, column=3, sticky="e") + + self._preclass_pattern_rows.append({ + "frame": row, + "key_entry": key_entry, + "mode_combo": mode_combo, + "value_entry": value_entry, + "remove_btn": remove_btn, + }) + + def get_preclass_patterns(self) -> list[dict]: + """Devuelve la lista de patrones de preclasificación definida en la pestaña.""" + out = [] + for it in getattr(self, "_preclass_pattern_rows", []): + try: + key = it["key_entry"].get().strip() + mode = (it["mode_combo"].get() or "").strip() + value = it["value_entry"].get().strip() + if not key: + continue + if mode == "key+value": + out.append({"key": key, "mode": "key+value", "value": value}) + else: + out.append({"key": key, "mode": "key"}) + except Exception: + pass + return out + + def get_doc_types(self) -> list[dict]: + out = [] + for it in self._doc_type_rows: + try: + name = it["name_entry"].get().strip() + kw = it["kw_entry"].get().strip() + if name and kw: + out.append({"name": name, "keyword": kw}) + except: pass + return out + + def set_doc_types(self, doc_types: list[dict]) -> None: + # Limpiar actuales + for it in self._doc_type_rows: + it["frame"].destroy() + self._doc_type_rows = [] + + if not doc_types: + self._add_doc_type_row("Factura", "Factura_O_Documento_Equivalente") + return + + for dt in doc_types: + self._add_doc_type_row(str(dt.get("name","")), str(dt.get("keyword",""))) + + def validate_doc_types(self) -> tuple[bool, str | None]: + seen = set() + for dt in self.get_doc_types(): + name = dt.get("name") + if name in seen: + return False, f"Nombre duplicado: {name}" + seen.add(name) + if not seen: + return False, "Debe haber al menos un tipo de documento." + return True, None diff --git a/gui_main.py b/gui_main.py new file mode 100644 index 0000000..7934587 --- /dev/null +++ b/gui_main.py @@ -0,0 +1,51 @@ +"""Entrada para la interfaz gráfica. + +Crea el servicio con el extractor por defecto y lanza la ventana principal. +""" +import logging + +import customtkinter as ctk + +from application.service import InvoiceProcessingService +from application.strategies import OllamaExtractor +from gui.view import InvoiceAppView +from gui.controller import AppController +from application.logging_config import configure_logging +from application.config import settings + + +def main(): + """Inicializa logging y arranca la aplicación GUI.""" + configure_logging() + + # Log effective settings *after* logging is configured + _cfg_logger = logging.getLogger("application.config") + try: + _cfg_logger.info( + "effective_api_settings", + extra={ + "component": "config", + "base_url": settings.api.base_url, + "model": settings.api.model, + "timeout_s": getattr(settings.api, "request_timeout_s", None), + }, + ) + except Exception: + pass + + extractor = OllamaExtractor( + api_key=settings.api.api_key, + base_url=settings.api.base_url, + model=settings.api.model + ) + service = InvoiceProcessingService(extractor) + + app = InvoiceAppView() + AppController(app, service) + app.mainloop() + + +if __name__ == "__main__": + main() + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b147a1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.vulture] +exclude = [".venv/", "dependencies/", "docs/", "logs/"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..21bf4e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,51 @@ +annotated-types==0.7.0 +anyio==4.11.0 +certifi==2025.8.3 +charset-normalizer==3.4.3 +colorama==0.4.6 +coloredlogs==15.0.1 +contourpy==1.3.3 +customtkinter==5.2.2 +cycler==0.12.1 +darkdetect==0.8.0 +distro==1.9.0 +flatbuffers==25.9.23 +fonttools==4.60.1 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +humanfriendly==10.0 +idna==3.10 +jiter==0.11.0 +kiwisolver==1.4.9 +matplotlib==3.10.7 +mpmath==1.3.0 +numpy==2.2.6 +ollama==0.6.0 +onnxruntime==1.23.2 +openai==2.1.0 +opencv-python==4.12.0.88 +packaging==25.0 +pdf2image==1.17.0 +pillow==11.3.0 +protobuf==6.33.0 +pyclipper==1.3.0.post6 +pydantic==2.11.9 +pydantic-settings==2.11.0 +pydantic_core==2.33.2 +pyparsing==3.2.5 +pyreadline3==3.5.4 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 +PyYAML==6.0.3 +rapidocr-onnxruntime==1.4.4 +requests==2.32.5 +shapely==2.1.2 +six==1.17.0 +sniffio==1.3.1 +sympy==1.14.0 +tenacity==9.1.2 +tqdm==4.67.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.5.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..94e545f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +# Asegurar que el directorio raíz del proyecto esté en sys.path para imports como `from application...` +ROOT = Path(__file__).resolve().parents[1] +root_str = str(ROOT) +if root_str not in sys.path: + sys.path.insert(0, root_str) + + diff --git a/tests/test_llm_smoke.py b/tests/test_llm_smoke.py new file mode 100644 index 0000000..38e6e94 --- /dev/null +++ b/tests/test_llm_smoke.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import pytest + +from application.config import settings +from application.llm_client import LLMClient + + +def test_llm_smoke_returns_ok(): + if not settings.api.api_key: + pytest.skip("Sin API key configurada; se omite prueba de integración.") + client = LLMClient() + text, usage = client.chat_text( + messages=[{"role": "user", "content": "Responde exactamente: ok"}], + component="test_llm_smoke", + ) + assert isinstance(text, str) + assert "ok" in text.lower() + if usage is not None: + assert isinstance(usage, dict) + # No todos los backends devuelven 'total_tokens'; validar presencia básica si existe + assert ("total_tokens" in usage) or ("prompt_tokens" in usage) or ("completion_tokens" in usage) + + diff --git a/tests/test_pdf_utils_tiff_range.py b/tests/test_pdf_utils_tiff_range.py new file mode 100644 index 0000000..f313e1b --- /dev/null +++ b/tests/test_pdf_utils_tiff_range.py @@ -0,0 +1,67 @@ +from PIL import Image + +from core.pdf_utils import tiff_to_images_bs64, document_to_images_bs64 + + +def _create_multipage_tiff(path: str, pages: int = 4) -> None: + """Crea un TIFF multipágina de prueba.""" + frames = [] + for i in range(pages): + # Colores distintos por página para facilitar depuración si es necesario + color = (50 * (i + 1) % 255, 0, 0) + frames.append(Image.new("RGB", (16, 16), color)) + frames[0].save(path, save_all=True, append_images=frames[1:], format="TIFF") + + +def test_tiff_to_images_bs64_respects_full_and_sub_ranges(tmp_path) -> None: + tiff_path = tmp_path / "sample.tif" + _create_multipage_tiff(str(tiff_path), pages=4) + + # Sin rango -> todas las páginas + imgs_all = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=None) + assert len(imgs_all) == 4 + + # Rango 2–3 (1-based, inclusivo) + imgs_2_3 = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(2, 3)) + assert len(imgs_2_3) == 2 + + +def test_tiff_to_images_bs64_allows_partial_ranges(tmp_path) -> None: + tiff_path = tmp_path / "sample_partial.tif" + _create_multipage_tiff(str(tiff_path), pages=4) + + # Desde la 3 hasta el final + imgs_from_3 = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(3, None)) # type: ignore[arg-type] + assert len(imgs_from_3) == 2 + + # Desde el inicio hasta la 2 + imgs_to_2 = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(None, 2)) # type: ignore[arg-type] + assert len(imgs_to_2) == 2 + + # Rango completamente fuera de límites -> lista vacía + imgs_out = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(10, 20)) + assert imgs_out == [] + + +def test_document_to_images_bs64_uses_explicit_tiff_page_range_over_default(tmp_path) -> None: + tiff_path = tmp_path / "doc.tif" + _create_multipage_tiff(str(tiff_path), pages=4) + + # Por defecto usa `tiff_pages=(2,3)` del helper + imgs_default = document_to_images_bs64(str(tiff_path), validate=True) + assert len(imgs_default) == 2 + + # tiff_page_range tiene prioridad sobre tiff_pages + imgs_range = document_to_images_bs64( + str(tiff_path), + validate=True, + tiff_pages=(1, 4), + tiff_page_range=(2, 3), + ) + assert len(imgs_range) == 2 + + # Sin rango ni valor por defecto -> todas las páginas + imgs_all = document_to_images_bs64(str(tiff_path), validate=True, tiff_pages=None) + assert len(imgs_all) == 4 + + diff --git a/tests/test_service_tiff_range.py b/tests/test_service_tiff_range.py new file mode 100644 index 0000000..788025b --- /dev/null +++ b/tests/test_service_tiff_range.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from application.service import InvoiceProcessingService +from application.strategies import InvoiceExtractorStrategy +import application.service as service_module + + +class DummyExtractor(InvoiceExtractorStrategy): + """Extractor mínimo para pruebas que devuelve un JSON fijo.""" + def __init__(self, fail_first: bool = False) -> None: + self.fail_first = fail_first + self.calls = 0 + + def extract(self, images_b64: List[str], instruction: str) -> Dict[str, Any]: + self.calls += 1 + if self.fail_first and self.calls == 1: + raise RuntimeError("forced failure") + return { + "raw": '{"ok": true}', + "usage": {}, + } + + +def test_extract_full_from_pdf_propagates_tiff_page_range(monkeypatch, tmp_path) -> None: + dummy_pdf = tmp_path / "doc.tif" + dummy_pdf.write_bytes(b"fake") + + captured: List[Dict[str, Any]] = [] + + def fake_document_to_images_bs64(path: str, **kwargs: Any) -> List[str]: + captured.append({"path": path, **kwargs}) + return ["img"] + + # Parchear el símbolo importado en el módulo de servicio + monkeypatch.setattr(service_module, "document_to_images_bs64", fake_document_to_images_bs64) + + service = InvoiceProcessingService(DummyExtractor()) + data, usage = service.extract_full_from_pdf(str(dummy_pdf), instruction="x", tiff_page_range=(2, 3)) + + assert data == {"ok": True} + assert isinstance(usage, dict) + assert captured, "Se esperaba al menos una llamada a document_to_images_bs64" + # Primera llamada (flujo normal) debe respetar el rango solicitado + assert captured[0].get("tiff_page_range") == (2, 3) + + +def test_extract_full_from_pdf_fallback_ignores_tiff_page_range(monkeypatch, tmp_path) -> None: + dummy_pdf = tmp_path / "doc2.tif" + dummy_pdf.write_bytes(b"fake") + + captured: List[Dict[str, Any]] = [] + + def fake_document_to_images_bs64(path: str, **kwargs: Any) -> List[str]: + captured.append({"path": path, **kwargs}) + return ["img"] + + monkeypatch.setattr(service_module, "document_to_images_bs64", fake_document_to_images_bs64) + + service = InvoiceProcessingService(DummyExtractor(fail_first=True)) + data, usage = service.extract_full_from_pdf(str(dummy_pdf), instruction="x", tiff_page_range=(2, 3)) + + assert data == {"ok": True} + assert isinstance(usage, dict) + # Deben existir dos llamadas: una normal (que falla el extractor) y otra fallback + assert len(captured) == 2 + assert captured[0].get("tiff_page_range") == (2, 3) + # En fallback NO debe propagarse el rango explícito + assert captured[1].get("tiff_page_range") is None + + diff --git a/tests/test_utils_patterns.py b/tests/test_utils_patterns.py new file mode 100644 index 0000000..b157bab --- /dev/null +++ b/tests/test_utils_patterns.py @@ -0,0 +1,43 @@ +from core.utils import _matches_pattern, find_first_matching_file + + +def test_matches_pattern_simple_contains(): + assert _matches_pattern("Factura_123.pdf", "factura") + assert _matches_pattern("otro_factura_1.tif", "FACTURA") + assert not _matches_pattern("recibo_123.txt", "factura") + + +def test_matches_pattern_wildcard_without_extension(): + # Patrón sin extensión se aplica sobre el nombre sin extensión + assert _matches_pattern("Factura_1.pdf", "*_1") + assert _matches_pattern("doc_OK1.tif", "doc_*1") + assert not _matches_pattern("Factura_10.pdf", "*_1") # termina en _10 + + +def test_matches_pattern_wildcard_with_extension(): + # Patrón con extensión válida se aplica al nombre completo + assert _matches_pattern("Factura_OK1.pdf", "*_OK?.pdf") + assert not _matches_pattern("Factura_OK1.tif", "*_OK?.pdf") + assert not _matches_pattern("Factura_OK12.pdf", "*_OK?.pdf") # demasiados caracteres + + +def test_matches_pattern_only_allowed_exts(): + # Extensiones que no sean pdf/tif/tiff se ignoran + assert not _matches_pattern("Factura_1.png", "*_1") + + +def test_find_first_matching_file_uses_helper_and_sorts(): + files = [ + "Z_Factura_1.pdf", + "A_Factura_1.pdf", + "B_Factura_2.tif", + "nota.txt", + ] + # Sin comodines: contiene + assert find_first_matching_file(files, "Factura_") == "A_Factura_1.pdf" + # Con comodines sin extensión + assert find_first_matching_file(files, "*_2") == "B_Factura_2.tif" + # Con comodines + extensión explícita + assert find_first_matching_file(files, "*_1.pdf") == "A_Factura_1.pdf" + +