feat: Enhance GUI and processing services with new batch processing and estimation features, update concurrency settings, and improve styling organization.

This commit is contained in:
erik
2026-03-01 19:56:10 -05:00
parent 69debb1c93
commit 3c258ebf12
14 changed files with 1777 additions and 1071 deletions
+5 -2
View File
@@ -6,7 +6,10 @@ import os
import logging import logging
import asyncio import asyncio
import openai 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.profiles import get_profile
from application.prompts import PromptBuilder from application.prompts import PromptBuilder
@@ -21,7 +24,7 @@ from core.csv_utils import rows_from_data
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BatchProcessor: class BatchProcessor:
def __init__(self, service): def __init__(self, service: 'InvoiceProcessingService'):
""" """
Args: Args:
service: Instancia de InvoiceProcessingService para delegar la extracción individual. service: Instancia de InvoiceProcessingService para delegar la extracción individual.
+1 -1
View File
@@ -50,7 +50,7 @@ class RetrySettings(BaseSettings):
class ProcessingSettings(BaseSettings): class ProcessingSettings(BaseSettings):
"""Configuración de procesamiento y concurrencia.""" """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") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+5 -2
View File
@@ -3,7 +3,10 @@
Encapsula la lógica de estimación previa al procesamiento. Encapsula la lógica de estimación previa al procesamiento.
""" """
import os 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.profiles import get_profile
from application.prompts import PromptBuilder 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 from core.utils import collect_radicado_docs, collect_documents_flat
class Estimator: class Estimator:
def __init__(self, service): def __init__(self, service: 'InvoiceProcessingService'):
self.service = service self.service = service
def estimate( def estimate(
+9 -4
View File
@@ -32,11 +32,16 @@ logger = logging.getLogger(__name__)
class InvoiceProcessingService: class InvoiceProcessingService:
"""Fachada de negocio para extraer y transformar datos de facturas.""" """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 self.extractor = extractor
# Componentes delegados # Componentes delegados inyectados mediante factories para evitar dependencia circular directa en construcción
self.batch_processor = BatchProcessor(self) self.batch_processor = batch_processor_factory(self)
self.estimator = Estimator(self) self.estimator = estimator_factory(self)
# Lock para escrituras concurrentes al mismo CSV desde múltiples hilos # Lock para escrituras concurrentes al mismo CSV desde múltiples hilos
# (Mantenido por retrocompatibilidad si se accede directo, aunque BatchProcessor usa su propio CSVManager) # (Mantenido por retrocompatibilidad si se accede directo, aunque BatchProcessor usa su propio CSVManager)
+4 -6
View File
@@ -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: __all__ = ["InvoiceAppView", "AppStyles"]
- `view`: ventana principal y widgets.
- `controller`: controlador de flujos de facturas.
- `field_selector`: diálogo para seleccionar campos y alias.
"""
+58
View File
@@ -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),
}
+8
View File
@@ -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"]
+438
View File
@@ -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)
+266
View File
@@ -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
+472
View File
@@ -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)
+251
View File
@@ -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("<<TreeviewSelect>>", 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)
+163
View File
@@ -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)
+88 -1055
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -38,7 +38,15 @@ def main():
base_url=settings.api.base_url, base_url=settings.api.base_url,
model=settings.api.model 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() app = InvoiceAppView()
AppController(app, service) AppController(app, service)