diff --git a/application/batch_processor.py b/application/batch_processor.py index 77ed714..c7672f9 100644 --- a/application/batch_processor.py +++ b/application/batch_processor.py @@ -6,7 +6,10 @@ import os import logging import asyncio import openai -from typing import List, Dict, Any, Optional, Tuple, Callable, Awaitable +from typing import List, Dict, Any, Optional, Tuple, Callable, Awaitable, TYPE_CHECKING + +if TYPE_CHECKING: + from application.service import InvoiceProcessingService from application.profiles import get_profile from application.prompts import PromptBuilder @@ -21,7 +24,7 @@ from core.csv_utils import rows_from_data logger = logging.getLogger(__name__) class BatchProcessor: - def __init__(self, service): + def __init__(self, service: 'InvoiceProcessingService'): """ Args: service: Instancia de InvoiceProcessingService para delegar la extracción individual. diff --git a/application/config.py b/application/config.py index 2de07ab..97625db 100644 --- a/application/config.py +++ b/application/config.py @@ -50,7 +50,7 @@ class RetrySettings(BaseSettings): class ProcessingSettings(BaseSettings): """Configuración de procesamiento y concurrencia.""" - concurrency_limit: int = Field(20, validation_alias=AliasChoices("CONCURRENCY_LIMIT")) + concurrency_limit: int = Field(100, validation_alias=AliasChoices("CONCURRENCY_LIMIT")) model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/application/estimator.py b/application/estimator.py index ca9112c..f496f05 100644 --- a/application/estimator.py +++ b/application/estimator.py @@ -3,7 +3,10 @@ Encapsula la lógica de estimación previa al procesamiento. """ import os -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from application.service import InvoiceProcessingService from application.profiles import get_profile from application.prompts import PromptBuilder @@ -13,7 +16,7 @@ from application.file_discovery import get_sorted_radicados, find_file_for_type_ from core.utils import collect_radicado_docs, collect_documents_flat class Estimator: - def __init__(self, service): + def __init__(self, service: 'InvoiceProcessingService'): self.service = service def estimate( diff --git a/application/service.py b/application/service.py index 4a751cf..b217368 100644 --- a/application/service.py +++ b/application/service.py @@ -32,11 +32,16 @@ logger = logging.getLogger(__name__) class InvoiceProcessingService: """Fachada de negocio para extraer y transformar datos de facturas.""" - def __init__(self, extractor: InvoiceExtractorStrategy) -> None: + def __init__( + self, + extractor: InvoiceExtractorStrategy, + batch_processor_factory: Callable[[InvoiceProcessingService], BatchProcessor], + estimator_factory: Callable[[InvoiceProcessingService], Estimator] + ) -> None: self.extractor = extractor - # Componentes delegados - self.batch_processor = BatchProcessor(self) - self.estimator = Estimator(self) + # Componentes delegados inyectados mediante factories para evitar dependencia circular directa en construcción + self.batch_processor = batch_processor_factory(self) + self.estimator = estimator_factory(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) diff --git a/gui/__init__.py b/gui/__init__.py index 448d382..f30ec26 100644 --- a/gui/__init__.py +++ b/gui/__init__.py @@ -1,7 +1,5 @@ -"""Paquete `gui`: componentes de la interfaz gráfica basada en customtkinter. +"""Módulo de GUI.""" +from gui.view import InvoiceAppView +from gui.styles import AppStyles -Incluye: -- `view`: ventana principal y widgets. -- `controller`: controlador de flujos de facturas. -- `field_selector`: diálogo para seleccionar campos y alias. -""" +__all__ = ["InvoiceAppView", "AppStyles"] diff --git a/gui/styles.py b/gui/styles.py new file mode 100644 index 0000000..4bcf3e5 --- /dev/null +++ b/gui/styles.py @@ -0,0 +1,58 @@ +"""Configuración centralizada de estilos, temas y fuentes para la GUI.""" +import customtkinter as ctk + + +class AppStyles: + """Clase con configuración de estilos y fuentes de la aplicación.""" + + @staticmethod + def configure_theme(): + """Configura el tema y apariencia general de la aplicación.""" + ctk.set_appearance_mode("System") + ctk.set_default_color_theme("blue") + + @staticmethod + def get_fonts(): + """Devuelve un diccionario con las fuentes personalizadas.""" + return { + "title": ctk.CTkFont(family="Roboto", size=22, weight="bold"), + "section": ctk.CTkFont(family="Roboto", size=16, weight="bold"), + "ui": ctk.CTkFont(family="Roboto", size=13), + "mono": ctk.CTkFont(family="Consolas", size=12), + "mono_small": ctk.CTkFont(family="Consolas", size=10), + "italic_small": ctk.CTkFont(size=12, slant="italic"), + "bold_large": ctk.CTkFont(size=16, weight="bold"), + "button_large": ctk.CTkFont(size=14, weight="bold"), + } + + @staticmethod + def get_colors(): + """Devuelve un diccionario con los colores personalizados.""" + return { + "primary_button": ("#106BA3", "#106BA3"), + "primary_hover": ("#0D5582", "#0D5582"), + "secondary_button": ("#2E86C1", "#2E86C1"), + "secondary_hover": ("#21618C", "#21618C"), + "success": ("#27AE60", "#27AE60"), + "success_hover": ("#1E8449", "#1E8449"), + "warning": ("#D35400", "#D35400"), + "warning_hover": ("#A04000", "#A04000"), + "danger": ("#C0392B", "#C0392B"), + "danger_hover": ("#A93226", "#A93226"), + "gray_light": "gray60", + "gray_dark": "gray70", + "text_muted": ("gray50", "gray70"), + } + + @staticmethod + def get_spacing(): + """Devuelve un diccionario con espaciados y padding estándar.""" + return { + "padding_small": 2, + "padding_medium": 5, + "padding_large": 10, + "padding_section": 15, + "margin_small": (0, 2), + "margin_medium": (5, 5), + "margin_large": (10, 15), + } diff --git a/gui/tabs/__init__.py b/gui/tabs/__init__.py new file mode 100644 index 0000000..e819434 --- /dev/null +++ b/gui/tabs/__init__.py @@ -0,0 +1,8 @@ +"""Módulo de pestañas de la GUI.""" +from gui.tabs.settings_tab import SettingsTab +from gui.tabs.results_tab import ResultsTab +from gui.tabs.main_tab import MainProcessingTab +from gui.tabs.preclass_tab import PreclassificationTab +from gui.tabs.metrics_tab import MetricsTab + +__all__ = ["SettingsTab", "ResultsTab", "MainProcessingTab", "PreclassificationTab", "MetricsTab"] diff --git a/gui/tabs/main_tab.py b/gui/tabs/main_tab.py new file mode 100644 index 0000000..0532211 --- /dev/null +++ b/gui/tabs/main_tab.py @@ -0,0 +1,438 @@ +"""Pestaña Procesar Documentos de la aplicación GUI.""" +import customtkinter as ctk +from gui.styles import AppStyles + + +class MainProcessingTab: + """Encapsula la UI de la pestaña Procesar Documentos.""" + + def __init__(self, parent_tab): + """Inicializa la pestaña de Procesar Documentos. + + Args: + parent_tab: El CTkFrame que representa la pestaña. + """ + self.tab = parent_tab + self.fonts = AppStyles.get_fonts() + self.colors = AppStyles.get_colors() + self.spacing = AppStyles.get_spacing() + + # Variables internas + self._doc_type_rows = [] + + self._setup_layout() + self._create_paths_section() + self._create_config_section() + self._create_process_button() + self._create_output_section() + + def _setup_layout(self): + """Configura el layout base de la pestaña.""" + self.tab.grid_columnconfigure(0, weight=1) + self.tab.grid_rowconfigure(0, weight=1) + + self.scroll_container = ctk.CTkScrollableFrame(self.tab, 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) + + def _create_paths_section(self): + """Crea la sección de rutas y archivos.""" + 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 + lbl_paths = ctk.CTkLabel( + self.frame_paths, + text="Rutas y Archivos", + font=self.fonts["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.fonts["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.fonts["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.fonts["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.fonts["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.fonts["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.fonts["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.fonts["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.fonts["ui"] + ) + 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.fonts["ui"] + ) + self.radio_general_main.pack(side="left") + + def _create_config_section(self): + """Crea la sección de configuración de tipos de documento y TIFF.""" + 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.fonts["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.fonts["ui"], + width=120, + height=28 + ) + self.add_doc_type_btn.grid(row=0, column=1, sticky="e") + self.add_doc_type_btn.configure(command=self._add_doc_type_row) + + # Lista de Tipos + 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) + + # 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.fonts["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.fonts["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) + self.fields_label = ctk.CTkLabel(self.tab, text="") + + def _create_process_button(self): + """Crea el botón de acción principal.""" + self.process_button = ctk.CTkButton( + self.scroll_container, + text="INICIAR PROCESAMIENTO", + font=self.fonts["bold_large"], + height=35, + fg_color=self.colors["primary_button"], + hover_color=self.colors["primary_hover"] + ) + self.process_button.grid(row=2, column=0, padx=15, pady=(10, 15), sticky="ew") + + def _create_output_section(self): + """Crea la sección de logs/salida.""" + 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.fonts["ui"], + text_color=self.colors["text_muted"] + ) + lbl_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0)) + + self.output_text = ctk.CTkTextbox( + self.log_frame, + font=self.fonts["mono"], + activate_scrollbars=True, + height=150 + ) + self.output_text.grid(row=1, column=0, padx=5, pady=5, sticky="ew") + + def _add_doc_type_row(self, name: str = "", keyword: str = "") -> None: + """Añade una fila de tipo de documento.""" + 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.fonts["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.fonts["ui"] + ) + if keyword: + kw_entry.insert(0, keyword) + kw_entry.grid(row=0, column=1, padx=(0, 5), sticky="ew") + + def _remove(): + # 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=self.colors["danger"], + hover_color=self.colors["danger_hover"], + 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 + }) + + # --- API Pública --- + + def set_on_select_root(self, callback): + """Vincula callback al botón de selección de carpeta raíz.""" + self.root_button.configure(command=callback) + + def set_on_select_csv(self, callback): + """Vincula callback al botón de selección de CSV.""" + self.csv_button.configure(command=callback) + + def set_on_process(self, callback): + """Vincula callback al botón de procesar.""" + self.process_button.configure(command=callback) + + def set_on_mode_change(self, callback): + """Vincula callback al cambio de modo.""" + self.mode_var.trace("w", lambda *args: callback()) + + def get_root_path(self) -> str: + """Devuelve la ruta raíz seleccionada.""" + return self.root_entry.get().strip() + + def get_csv_path(self) -> str: + """Devuelve la ruta del CSV.""" + return self.csv_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_keywords(self) -> str: + """Devuelve los keywords.""" + return self.kw_entry.get().strip() + + def get_fields(self) -> str: + """Devuelve los fields (legacy).""" + return self.fields_entry.get().strip() + + def get_tiff_start_page(self) -> int: + """Devuelve la página de inicio TIFF.""" + 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: + """Devuelve la página final TIFF.""" + try: + value = self.tiff_end_entry.get().strip() + return int(value) if value else 0 + except Exception: + return 0 + + def show_tiff_range_controls(self, show: bool) -> None: + """Muestra u oculta los controles de rango TIFF.""" + 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_doc_types(self) -> list[dict]: + """Devuelve la lista de tipos de documento.""" + 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: + """Establece los tipos de documento.""" + 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]: + """Valida los tipos de documento.""" + 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 + + 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") + + # Limpiar valor por defecto si existe + 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 append_output(self, text: str) -> None: + """Añade texto al área de salida.""" + self.output_text.insert("end", text + "\n") + self.output_text.see("end") + + def append_output_safe(self, text: str) -> None: + """Añade texto al área de salida de forma thread-safe.""" + # Nota: se espera que sea llamado con self.after(0, ...) + self.append_output(text) + + def enable_controls(self, enabled: bool): + """Habilita o deshabilita los controles.""" + state = "normal" if enabled else "disabled" + + self.process_button.configure(state=state, text="PROCESANDO..." if not enabled else "INICIAR PROCESAMIENTO") + if not enabled: + self.process_button.configure(fg_color="gray") + else: + self.process_button.configure(fg_color=self.colors["primary_button"]) + + 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) diff --git a/gui/tabs/metrics_tab.py b/gui/tabs/metrics_tab.py new file mode 100644 index 0000000..0f6ff07 --- /dev/null +++ b/gui/tabs/metrics_tab.py @@ -0,0 +1,266 @@ +"""Pestaña Dashboard & Métricas de la aplicación GUI.""" +import customtkinter as ctk +import tkinter.ttk as ttk +from gui.styles import AppStyles + +# matplotlib es opcional +try: + from matplotlib.figure import Figure + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + HAS_MPL = True +except Exception: + HAS_MPL = False + + +class MetricsTab: + """Encapsula la UI de la pestaña Dashboard & Métricas.""" + + def __init__(self, parent_tab): + """Inicializa la pestaña de Métricas. + + Args: + parent_tab: El CTkFrame que representa la pestaña. + """ + self.tab = parent_tab + self.fonts = AppStyles.get_fonts() + self.colors = AppStyles.get_colors() + self.spacing = AppStyles.get_spacing() + + # Variables internas para gráficos + self._cost_line_points_x = [] + self._cost_line_points_y = [] + + self._setup_layout() + self._create_kpi_section() + self._create_charts_section() + self._create_comparison_section() + self._create_usage_section() + + def _setup_layout(self): + """Configura el layout base de la pestaña.""" + self.tab.grid_columnconfigure(0, weight=1) + self.tab.grid_rowconfigure(1, weight=1) + self.tab.grid_rowconfigure(3, weight=1) + + def _create_kpi_section(self): + """Crea la sección de KPIs.""" + self.kpi_frame = ctk.CTkFrame(self.tab) + 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.fonts["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") + + def _create_charts_section(self): + """Crea la sección de gráficos.""" + self.charts_frame = ctk.CTkFrame(self.tab) + 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 + if HAS_MPL: + self.fig_line = Figure(figsize=(4, 3), dpi=100) + self.fig_line.patch.set_facecolor('#f0f0f0') + 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") + + def _create_comparison_section(self): + """Crea la sección de comparación de tarifas.""" + self.comparison_frame = ctk.CTkFrame(self.tab) + 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 + 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)) + + def _create_usage_section(self): + """Crea la sección de detalle de uso.""" + self.usage_text = ctk.CTkTextbox(self.tab, height=150, font=self.fonts["mono"]) + self.usage_text.grid(row=3, column=0, sticky="ew", padx=15, pady=15) + + # --- API Pública --- + + def append_usage(self, text: str) -> None: + """Añade texto al área de uso.""" + self.usage_text.insert("end", text + "\n") + self.usage_text.see("end") + + def append_usage_safe(self, text: str) -> None: + """Añade texto al área de uso de forma thread-safe.""" + self.append_usage(text) + + def clear(self) -> None: + """Limpia todos los datos de la pestaña.""" + 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): + """Legacy method, handled by KPIs now.""" + pass + + def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None: + """Actualiza los KPIs con los datos proporcionados.""" + 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: + """Actualiza el gráfico de línea de costo acumulado.""" + 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: + """Actualiza el gráfico de barras de tokens.""" + 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 comparación de tarifas.""" + 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: + """Añade una fila de detalle de uso.""" + 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 diff --git a/gui/tabs/preclass_tab.py b/gui/tabs/preclass_tab.py new file mode 100644 index 0000000..b688aab --- /dev/null +++ b/gui/tabs/preclass_tab.py @@ -0,0 +1,472 @@ +"""Pestaña Preclasificación de la aplicación GUI.""" +import customtkinter as ctk +from gui.styles import AppStyles + + +class PreclassificationTab: + """Encapsula la UI de la pestaña Preclasificación.""" + + def __init__(self, parent_tab): + """Inicializa la pestaña de Preclasificación. + + Args: + parent_tab: El CTkFrame que representa la pestaña. + """ + self.tab = parent_tab + self.fonts = AppStyles.get_fonts() + self.colors = AppStyles.get_colors() + self.spacing = AppStyles.get_spacing() + + # Variables internas + self._preclass_pattern_rows = [] + + self._setup_layout() + self._create_root_section() + self._create_options_section() + self._create_target_section() + self._create_patterns_section() + self._create_action_section() + self._create_output_section() + + def _setup_layout(self): + """Configura el layout base de la pestaña.""" + self.tab.grid_columnconfigure(0, weight=1) + self.tab.grid_rowconfigure(0, weight=1) + + self.preclass_container = ctk.CTkScrollableFrame(self.tab, 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) + self.preclass_container.grid_rowconfigure(3, weight=1) + self.preclass_container.grid_rowconfigure(5, weight=1) + + def _create_root_section(self): + """Crea la sección de 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.fonts["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.fonts["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.fonts["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.fonts["ui"], + width=100 + ) + self.preclass_root_button.grid(row=1, column=2, padx=15, pady=(0, 10)) + + # Selector de Modo + self.preclass_mode_var = ctk.StringVar(value="radicados") + + lbl_mode = ctk.CTkLabel(self.preclass_frame_root, text="Estructura:", font=self.fonts["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.fonts["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.fonts["ui"] + ) + self.radio_general.pack(side="left") + + def _create_options_section(self): + """Crea la sección de opciones de rango.""" + 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.fonts["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") + + lbl_desde = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Desde:", font=self.fonts["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.fonts["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.fonts["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.fonts["ui"]) + self.preclass_range_end_entry.insert(0, "1") + self.preclass_range_end_entry.grid(row=0, column=3, padx=5, pady=5) + + def _create_target_section(self): + """Crea la sección de archivo objetivo.""" + 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.fonts["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") + + lbl_target_def = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Def. Tipo Doc:", font=self.fonts["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.fonts["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.fonts["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.fonts["ui"], + placeholder_text="Ej: *_FAC_*.pdf" + ) + self.preclass_target_pattern_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w") + + def _create_patterns_section(self): + """Crea la sección de 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.fonts["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.fonts["ui"], + width=140, + height=28 + ) + self.add_preclass_pattern_btn.grid(row=0, column=1, sticky="e") + self.add_preclass_pattern_btn.configure(command=self._add_preclass_pattern_row) + + 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=self.fonts["italic_small"], + text_color=self.colors["gray_light"], + 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) + + # Fila por defecto (vacía) + self._add_preclass_pattern_row() + + def _create_action_section(self): + """Crea la sección de acciones.""" + 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=self.fonts["bold_large"], + height=40, + fg_color=self.colors["secondary_button"], + hover_color=self.colors["secondary_hover"] + ) + 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.fonts["ui"], + height=40, + state="disabled", + fg_color=self.colors["success"], + hover_color=self.colors["success_hover"] + ) + 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.fonts["ui"], + height=40, + state="disabled", + fg_color=self.colors["warning"], + hover_color=self.colors["warning_hover"] + ) + self.preclass_organize_btn.pack(side="left", padx=(0, 10)) + + def _create_output_section(self): + """Crea la sección de salida/logs.""" + 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) + + lbl_pre_logs = ctk.CTkLabel( + self.preclass_log_frame, + text="Resultados:", + font=self.fonts["ui"], + text_color=self.colors["text_muted"] + ) + 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.fonts["mono"], + activate_scrollbars=True + ) + self.preclass_output.grid(row=1, column=0, padx=5, pady=5, sticky="nsew") + + 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.fonts["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.fonts["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.fonts["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: + value_entry.delete(0, "end") + value_entry.configure(state="disabled", placeholder_text="Value (no aplica)") + + def _on_mode_change(choice: str): + _apply_mode(choice) + + 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=self.colors["danger"], + hover_color=self.colors["danger_hover"], + 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 _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() + + # --- API Pública --- + + def set_on_run(self, callback): + """Vincula callback al botón de ejecutar.""" + self.preclass_run_btn.configure(command=callback) + + def set_on_export_excel(self, callback): + """Vincula callback al botón de exportar Excel.""" + self.preclass_export_btn.configure(command=callback) + + def set_on_organize_files(self, callback): + """Vincula callback al botón de organizar carpetas.""" + self.preclass_organize_btn.configure(command=callback) + + def set_on_select_root(self, callback): + """Vincula callback al botón de seleccionar carpeta.""" + self.preclass_root_button.configure(command=callback) + + def get_root_path(self) -> str: + """Devuelve la ruta raíz de preclasificación.""" + return self.preclass_root_entry.get().strip() + + def get_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_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_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 get_patterns(self) -> list[dict]: + """Devuelve la lista de patrones definida.""" + 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 append_output(self, text: str) -> None: + """Añade texto al área de salida.""" + self.preclass_output.insert("end", text + "\n") + self.preclass_output.see("end") + + def append_output_safe(self, text: str) -> None: + """Añade texto al área de salida de forma thread-safe.""" + self.append_output(text) + + def enable_actions(self, enabled: bool): + """Habilita o deshabilita los botones de acción.""" + state = "normal" if enabled else "disabled" + self.preclass_export_btn.configure(state=state) + self.preclass_organize_btn.configure(state=state) + + def enable_controls(self, enabled: bool): + """Habilita o deshabilita los controles principales.""" + state = "normal" if enabled else "disabled" + + 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.add_preclass_pattern_btn.configure(state=state) + self.preclass_run_btn.configure(state=state) + + 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) diff --git a/gui/tabs/results_tab.py b/gui/tabs/results_tab.py new file mode 100644 index 0000000..ee95aa4 --- /dev/null +++ b/gui/tabs/results_tab.py @@ -0,0 +1,251 @@ +"""Pestaña de Resultados de la aplicación GUI.""" +import os +import customtkinter as ctk +import tkinter.ttk as ttk +from gui.styles import AppStyles + + +class ResultsTab: + """Encapsula la UI de la pestaña Resultados.""" + + def __init__(self, parent_tab): + """Inicializa la pestaña de Resultados. + + Args: + parent_tab: El CTkFrame que representa la pestaña. + """ + self.tab = parent_tab + self.fonts = AppStyles.get_fonts() + self.colors = AppStyles.get_colors() + self.spacing = AppStyles.get_spacing() + + self._setup_layout() + self._create_subtabs() + self._setup_treeview_style() + self._create_extractor_tab() + self._create_preclass_results_tab() + + def _setup_layout(self): + """Configura el layout base de la pestaña.""" + self.tab.grid_columnconfigure(0, weight=1) + self.tab.grid_rowconfigure(0, weight=1) + + self.tabs_res = ctk.CTkTabview(self.tab) + self.tabs_res.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) + + def _create_subtabs(self): + """Crea las sub-pestañas de Resultados.""" + self.tab_res_extractor = self.tabs_res.add("Extractor (Próximamente)") + self.tab_res_preclass = self.tabs_res.add("Preclasificación") + + def _setup_treeview_style(self): + """Configura el estilo de los treeviews.""" + style = ttk.Style() + style.theme_use("clam") + style.configure("Treeview", rowheight=25) + + def _create_extractor_tab(self): + """Crea la sub-pestaña del Extractor (en construcción).""" + 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") + + def _create_preclass_results_tab(self): + """Crea la sub-pestaña de resultados de preclasificación.""" + self.tab_res_preclass.grid_columnconfigure(0, weight=1) + self.tab_res_preclass.grid_rowconfigure(1, weight=1) + + self._create_filters_section() + self._create_tables_section() + self._create_actions_section() + + def _create_filters_section(self): + """Crea la sección de 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) + + def _create_tables_section(self): + """Crea la sección con las tablas (principal y de detalle).""" + 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) + self.frame_res_table.grid_rowconfigure(2, weight=2) + + self._create_main_results_table() + self._create_detail_label() + self._create_matches_table() + + def _create_main_results_table(self): + """Crea la tabla principal de resultados.""" + 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 + 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") + + def _create_detail_label(self): + """Crea la etiqueta de la sección de 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)) + + def _create_matches_table(self): + """Crea la tabla de detalle de matches.""" + 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") + + def _create_actions_section(self): + """Crea la sección de 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=self.colors["success"] + ) + 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=self.colors["warning"], + hover_color=self.colors["warning_hover"] + ) + 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) + + # --- API Pública --- + + def set_on_search(self, callback): + """Vincula el callback al botón de búsqueda.""" + self.btn_res_search.configure(command=callback) + + def set_on_export(self, callback): + """Vincula el callback al botón de exportación.""" + self.btn_res_export.configure(command=callback) + + def set_on_refresh(self, callback): + """Vincula el callback al botón de refrescar.""" + self.btn_res_refresh.configure(command=callback) + + def set_on_organize(self, callback): + """Vincula el callback al botón de organizar.""" + self.btn_res_organize.configure(command=callback) + + def set_on_select_result(self, callback): + """Vincula el callback al evento de selección de fila.""" + self.tree_results.bind("<>", callback) + + def update_matches_table(self, rows): + """Actualiza la tabla de matches con los datos proporcionados.""" + for item in self.tree_matches.get_children(): + self.tree_matches.delete(item) + + for r in rows: + 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_runs_combo(self, runs: list): + """Actualiza el combo box con los IDs de ejecuciones disponibles.""" + vals = ["Todos"] + [str(r) for r in runs] + self.combo_res_run.configure(values=vals) + self.combo_res_run.set("Todos") + + def set_run_id(self, run_id: int | str): + """Establece el ID de ejecución seleccionado.""" + try: + self.combo_res_run.set(str(run_id)) + except Exception: + pass + + def get_filters(self) -> dict: + """Devuelve los filtros actuales.""" + return { + "run_id": self.combo_res_run.get(), + "status": self.combo_res_status.get(), + "text": self.entry_res_search.get() + } + + def update_results_table(self, rows): + """Actualiza la tabla de resultados con los datos proporcionados.""" + for item in self.tree_results.get_children(): + self.tree_results.delete(item) + + for r in rows: + 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) diff --git a/gui/tabs/settings_tab.py b/gui/tabs/settings_tab.py new file mode 100644 index 0000000..1c807d8 --- /dev/null +++ b/gui/tabs/settings_tab.py @@ -0,0 +1,163 @@ +"""Pestaña de Configuración de la aplicación GUI.""" +import customtkinter as ctk +from gui.styles import AppStyles + + +class SettingsTab: + """Encapsula la UI de la pestaña Configuración.""" + + def __init__(self, parent_tab): + """Inicializa la pestaña de Configuración. + + Args: + parent_tab: El CTkFrame que representa la pestaña. + """ + self.tab = parent_tab + self.fonts = AppStyles.get_fonts() + self.colors = AppStyles.get_colors() + self.spacing = AppStyles.get_spacing() + + self._setup_layout() + self._create_api_section() + self._create_processing_section() + self._create_save_button() + + def _setup_layout(self): + """Configura el layout base de la pestaña.""" + self.tab.grid_columnconfigure(0, weight=1) + self.tab.grid_rowconfigure(0, weight=1) + + self.settings_container = ctk.CTkFrame(self.tab, 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) + + def _create_api_section(self): + """Crea la sección de configuración del modelo LLM.""" + 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) + + # Título + lbl_api_title = ctk.CTkLabel( + self.frame_api, + text="Configuración del Modelo LLM", + font=self.fonts["section"] + ) + lbl_api_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10) + + # Selector de modelo + lbl_model = ctk.CTkLabel( + self.frame_api, + text="Modelo (Ollama):", + font=self.fonts["ui"] + ) + 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.fonts["ui"] + ) + self.combo_model.grid(row=1, column=1, padx=15, pady=10, sticky="w") + self.combo_model.set("4.1") + + # Texto de ayuda + help_text = ( + "ℹ 4.1 mini: Muy rápido (recomendado)\n" + "ℹ 4.1: Estándar (balanceado)\n" + "ℹ 5.1: Lento pero mayor razonamiento" + ) + lbl_help = ctk.CTkLabel( + self.frame_api, + text=help_text, + font=self.fonts["italic_small"], + text_color=self.colors["gray_light"], + justify="left" + ) + lbl_help.grid(row=2, column=1, padx=15, pady=(0, 10), sticky="w") + + def _create_processing_section(self): + """Crea la sección de procesamiento y rendimiento.""" + 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) + + # Título + lbl_proc_title = ctk.CTkLabel( + self.frame_proc, + text="Procesamiento y Rendimiento", + font=self.fonts["section"] + ) + lbl_proc_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10) + + # Límite de concurrencia + lbl_conc = ctk.CTkLabel( + self.frame_proc, + text="Límite de Concurrencia:", + font=self.fonts["ui"] + ) + lbl_conc.grid(row=1, column=0, padx=15, pady=10, sticky="w") + + self.concurrency_var = ctk.StringVar(value="100") + self.concurrency_entry = ctk.CTkEntry( + self.frame_proc, + textvariable=self.concurrency_var, + width=100, + font=self.fonts["ui"] + ) + self.concurrency_entry.grid(row=1, column=1, padx=15, pady=10, sticky="w") + + # Advertencia + warning_text = ( + "⚠️ Advertencia: Entre mayor el número, mayor la probabilidad de Rate Limits (429)." + ) + lbl_conc_warn = ctk.CTkLabel( + self.frame_proc, + text=warning_text, + font=self.fonts["italic_small"], + text_color="orange" + ) + lbl_conc_warn.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 10), sticky="w") + + def _create_save_button(self): + """Crea el botón de guardar configuración.""" + self.btn_save_settings = ctk.CTkButton( + self.settings_container, + text="Aplicar Configuración", + font=self.fonts["ui"], + height=35 + ) + self.btn_save_settings.grid(row=2, column=0, padx=15, pady=15, sticky="ew") + + # --- API Pública --- + + def set_on_save(self, callback): + """Vincula el callback al botón de guardar.""" + self.btn_save_settings.configure(command=callback) + + def get_selected_model(self) -> str: + """Devuelve el modelo seleccionado.""" + return self.combo_model.get() + + def set_selected_model(self, model: str) -> None: + """Establece el modelo seleccionado.""" + self.combo_model.set(model) + + def get_concurrency_limit(self) -> int: + """Devuelve el límite de concurrencia.""" + try: + return int(self.concurrency_var.get()) + except Exception: + return 20 + + def set_concurrency_limit(self, limit: int) -> None: + """Establece el límite de concurrencia.""" + self.concurrency_var.set(str(limit)) + + def enable_controls(self, enabled: bool): + """Habilita o deshabilita los controles.""" + state = "normal" if enabled else "disabled" + self.combo_model.configure(state=state) + self.concurrency_entry.configure(state=state) + self.btn_save_settings.configure(state=state) diff --git a/gui/view.py b/gui/view.py index 957fe79..a762048 100644 --- a/gui/view.py +++ b/gui/view.py @@ -5,13 +5,13 @@ Expone métodos para enlazar callbacks, leer entradas y escribir salida en un """ import os # Necesario para basename en update_res_table import customtkinter as ctk +from gui.styles import AppStyles +from gui.tabs import SettingsTab, ResultsTab, MainProcessingTab, PreclassificationTab, MetricsTab # 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 @@ -25,14 +25,18 @@ class InvoiceAppView(ctk.CTk): 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) + # Configurar tema + AppStyles.configure_theme() + + # Obtener estilos + self.fonts = AppStyles.get_fonts() + self.colors = AppStyles.get_colors() + + # Retrocompatibilidad: usar los nombres antiguos + self.font_title = self.fonts["title"] + self.font_section = self.fonts["section"] + self.font_ui = self.fonts["ui"] + self.font_mono = self.fonts["mono"] # Layout principal self.grid_columnconfigure(0, weight=1) @@ -51,642 +55,29 @@ class InvoiceAppView(ctk.CTk): # ========================================================================= # --- 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") + self.main_tab = MainProcessingTab(self.tab_main) # ========================================================================= # --- 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) + self.results_tab = ResultsTab(self.tab_results) # ========================================================================= # --- 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") + self.settings_tab = SettingsTab(self.tab_settings) # ========================================================================= # --- 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") + self.preclass_tab = PreclassificationTab(self.tab_preclass) # ========================================================================= # --- 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) + self.metrics_tab = MetricsTab(self.tab_usage) # --------------------------------------------------------------------------- @@ -694,545 +85,187 @@ class InvoiceAppView(ctk.CTk): # --------------------------------------------------------------------------- def set_on_select_root(self, callback): - self.root_button.configure(command=callback) + self.main_tab.set_on_select_root(callback) def set_on_select_preclass_root(self, callback): - self.preclass_root_button.configure(command=callback) + self.preclass_tab.set_on_select_root(callback) def set_on_process(self, callback): - self.process_button.configure(command=callback) + self.main_tab.set_on_process(callback) def set_on_select_csv(self, callback): - self.csv_button.configure(command=callback) + self.main_tab.set_on_select_csv(callback) def set_on_save_settings(self, callback): - self.btn_save_settings.configure(command=callback) + self.settings_tab.set_on_save(callback) + + def get_selected_model(self) -> str: + return self.settings_tab.get_selected_model() + + def set_selected_model(self, model: str) -> None: + self.settings_tab.set_selected_model(model) + + def get_concurrency_limit(self) -> int: + return self.settings_tab.get_concurrency_limit() + + def set_concurrency_limit(self, limit: int) -> None: + self.settings_tab.set_concurrency_limit(limit) def set_on_run_preclassification(self, callback): - self.preclass_run_btn.configure(command=callback) + self.preclass_tab.set_on_run(callback) def set_on_export_excel(self, callback): - self.preclass_export_btn.configure(command=callback) + self.preclass_tab.set_on_export_excel(callback) def set_on_organize_files(self, callback): - self.preclass_organize_btn.configure(command=callback) + self.preclass_tab.set_on_organize_files(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) + self.preclass_tab.enable_actions(enabled) # --- Métodos Resultados --- def set_on_res_search(self, callback): - self.btn_res_search.configure(command=callback) + self.results_tab.set_on_search(callback) def set_on_res_export(self, callback): - self.btn_res_export.configure(command=callback) + self.results_tab.set_on_export(callback) def set_on_res_refresh(self, callback): - self.btn_res_refresh.configure(command=callback) + self.results_tab.set_on_refresh(callback) def set_on_res_organize(self, callback): - self.btn_res_organize.configure(command=callback) + self.results_tab.set_on_organize(callback) def set_on_res_select(self, callback): - # Bind TreeviewSelect event - self.tree_results.bind("<>", callback) + self.results_tab.set_on_select_result(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) + self.results_tab.update_matches_table(rows) 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") + self.results_tab.update_runs_combo(runs) def set_res_run(self, run_id: int | str): - try: - self.combo_res_run.set(str(run_id)) - except Exception: - pass + self.results_tab.set_run_id(run_id) 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() - } + return self.results_tab.get_filters() 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) + self.results_tab.update_results_table(rows) def append_preclass_output(self, text: str) -> None: - self.preclass_output.insert("end", text + "\n") - self.preclass_output.see("end") + self.preclass_tab.append_output(text) 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)) + self.after(0, lambda: self.preclass_tab.append_output_safe(text)) 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() + self.main_tab.update_ui_for_mode() def get_root_path(self) -> str: - return self.root_entry.get().strip() + return self.main_tab.get_root_path() 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" + return self.main_tab.get_process_mode() def get_preclass_root_path(self) -> str: - return self.preclass_root_entry.get().strip() + return self.preclass_tab.get_root_path() 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" + return self.preclass_tab.get_mode() 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 + return self.preclass_tab.get_range() 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() + return self.preclass_tab.get_target_info() def get_keywords(self) -> str: - return self.kw_entry.get().strip() + return self.main_tab.get_keywords() def get_fields(self) -> str: - return self.fields_entry.get().strip() + return self.main_tab.get_fields() def get_csv_path(self) -> str: - return self.csv_entry.get().strip() + return self.main_tab.get_csv_path() 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() + self.main_tab.show_tiff_range_controls(show) 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 + return self.main_tab.get_tiff_start_page() 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 + return self.main_tab.get_tiff_end_page() def append_output(self, text: str) -> None: - self.output_text.insert("end", text + "\n") - self.output_text.see("end") + self.main_tab.append_output(text) def append_output_safe(self, text: str) -> None: - self.after(0, lambda: self.append_output(text)) + self.after(0, lambda: self.main_tab.append_output_safe(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.main_tab.enable_controls(not processing) - 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") + # Actualizar controles de la pestaña de preclasificación + self.preclass_tab.enable_controls(not processing) - 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) + # Actualizar controles de la pestaña de configuración + self.settings_tab.enable_controls(not processing) except Exception: pass # --- TAB USO --- def append_usage(self, text: str) -> None: - self.usage_text.insert("end", text + "\n") - self.usage_text.see("end") + self.metrics_tab.append_usage(text) def append_usage_safe(self, text: str) -> None: - self.after(0, lambda: self.append_usage(text)) + self.after(0, lambda: self.metrics_tab.append_usage_safe(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() + self.metrics_tab.clear() def set_usage_totals(self, **kwargs): - pass # Legacy, handled by KPIs now + self.metrics_tab.set_usage_totals(**kwargs) 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)) + self.metrics_tab.update_kpis(overall, extract_phase, 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 + self.metrics_tab.update_cost_line(doc_index, total_cost_usd) - 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_token_bars(self, input_extract: int, cached_extract: int, output_extract: int, + input_norm: int, cached_norm: int, output_norm: int) -> None: + self.metrics_tab.update_token_bars(input_extract, cached_extract, output_extract, + input_norm, cached_norm, output_norm) 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 + self.metrics_tab.update_comparison_table(comparisons) 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 + self.metrics_tab.append_usage_row(phase, radicado, usage, cost) # --- 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 + return self.preclass_tab.get_patterns() 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 + return self.main_tab.get_doc_types() 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",""))) + self.main_tab.set_doc_types(doc_types) 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 + return self.main_tab.validate_doc_types() diff --git a/gui_main.py b/gui_main.py index 7934587..9f35e62 100644 --- a/gui_main.py +++ b/gui_main.py @@ -38,7 +38,15 @@ def main(): base_url=settings.api.base_url, model=settings.api.model ) - service = InvoiceProcessingService(extractor) + + from application.batch_processor import BatchProcessor + from application.estimator import Estimator + + service = InvoiceProcessingService( + extractor, + batch_processor_factory=lambda s: BatchProcessor(s), + estimator_factory=lambda s: Estimator(s) + ) app = InvoiceAppView() AppController(app, service)