feat: Introduce build and installation scripts for IPSExtractor, including a batch file for PyInstaller and Inno Setup integration, and add application preferences management for document types and retry logic in the GUI.

This commit is contained in:
erik
2026-04-02 16:32:36 -05:00
parent a1b0526080
commit 7a38b0843b
13 changed files with 845 additions and 62 deletions
+125 -6
View File
@@ -13,6 +13,7 @@ import os
from core.utils import parse_comma_separated, collect_radicado_docs, collect_documents_flat
from application.logging_config import configure_logging
from application.app_prefs import load_app_prefs, save_app_prefs, normalize_document_types
from gui.field_selector import FieldSelectorDialog
from application.preclassification import PreclassificationService
from application.strategies import OllamaExtractor
@@ -111,11 +112,11 @@ class AppController:
# Mapeo de nombres de UI a IDs de modelo (y viceversa)
self.ui_to_model = {
"5.1": "gpt-5.1",
"4.1": "gpt-4.1",
"4.1 mini": "gpt-4.1-mini",
"4.1 nano": "gpt-4.1-nano",
"5.1 Codex mini": "gpt-5.1-codex-mini",
"5.4": "gpt-5.4",
"5.4-mini": "gpt-5.4-mini",
"5.4-nano": "gpt-5.4-nano",
"5.2": "gpt-5.2",
"4.1-mini": "gpt-4.1-mini",
}
self.model_to_ui = {v: k for k, v in self.ui_to_model.items()}
@@ -136,6 +137,12 @@ class AppController:
except Exception:
pass
self._load_persisted_preferences()
try:
self.view.protocol("WM_DELETE_WINDOW", self.on_app_close)
except Exception:
pass
def _get_model_pricing_data(self):
"""Obtiene las tarifas por 1M de tokens para cada modelo."""
results = []
@@ -173,6 +180,8 @@ class AppController:
# Actualizar settings globales
settings.processing.concurrency_limit = concurrency
self.view.set_allowed_document_types_hint(self.view.get_allowed_document_types())
self._save_preferences()
mb.showinfo("Configuración", f"Configuración actualizada.\nModelo: {selected_name}\nConcurrencia: {concurrency}")
self.view.append_output_safe(f"Configuración actualizada: Modelo={real_model_id}, Concurrencia={concurrency}")
@@ -190,6 +199,7 @@ class AppController:
if not selected:
return
self.view.set_root_path(selected)
self._save_preferences()
# Detectar si el primer documento a procesar es TIFF para esta carpeta
try:
@@ -468,6 +478,97 @@ class AppController:
selected = fd.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV", "*.csv"), ("Todos", "*.*")])
if selected:
self.view.set_csv_path(selected)
self._save_preferences()
def _collect_preferences(self) -> dict:
"""Toma una foto de la configuración visible de la app."""
try:
doc_types = self.view.get_doc_types()
except Exception:
doc_types = []
return {
"selected_model": self.view.get_selected_model(),
"concurrency_limit": self.view.get_concurrency_limit(),
"allowed_document_types": self.view.get_allowed_document_types(),
"retry_on_empty": self.view.get_retry_empty_enabled(),
"process_mode": self.view.get_process_mode(),
"root_path": self.view.get_root_path(),
"csv_path": self.view.get_csv_path(),
"doc_types": doc_types,
}
def _save_preferences(self) -> None:
"""Guarda preferencias de la GUI en disco."""
save_app_prefs(self._collect_preferences())
def _load_persisted_preferences(self) -> None:
"""Carga preferencias persistidas y las aplica a la vista/servicio."""
prefs = load_app_prefs()
if not prefs:
return
try:
selected_model = str(prefs.get("selected_model") or "").strip()
if selected_model:
self.view.set_selected_model(selected_model)
real_model_id = self.ui_to_model.get(selected_model, selected_model)
if hasattr(self.service.extractor, "update_config"):
self.service.extractor.update_config(model=real_model_id)
except Exception:
logger.exception("No se pudo restaurar el modelo persistido")
try:
concurrency = int(prefs.get("concurrency_limit") or settings.processing.concurrency_limit)
self.view.set_concurrency_limit(concurrency)
settings.processing.concurrency_limit = concurrency
except Exception:
pass
try:
allowed_document_types = normalize_document_types(prefs.get("allowed_document_types"))
self.view.set_allowed_document_types(allowed_document_types)
self.view.set_allowed_document_types_hint(allowed_document_types)
except Exception:
pass
try:
self.view.set_retry_empty_enabled(bool(prefs.get("retry_on_empty", False)))
except Exception:
pass
try:
self.view.set_process_mode(str(prefs.get("process_mode") or "radicados"))
except Exception:
pass
try:
root_path = str(prefs.get("root_path") or "").strip()
if root_path:
self.view.set_root_path(root_path)
except Exception:
pass
try:
csv_path = str(prefs.get("csv_path") or "").strip()
if csv_path:
self.view.set_csv_path(csv_path)
except Exception:
pass
try:
doc_types = prefs.get("doc_types")
if isinstance(doc_types, list):
self.view.set_doc_types(doc_types)
except Exception:
pass
def on_app_close(self):
"""Guarda preferencias antes de cerrar la ventana."""
try:
self._save_preferences()
finally:
self.view.destroy()
def on_process(self):
"""Inicia el flujo de procesamiento por radicado (solo facturas) en un hilo de fondo.
@@ -475,6 +576,7 @@ class AppController:
- Si el servicio requiere selección de campos, abre `FieldSelectorDialog` y relanza.
- Emite resultados en el área de salida y opcionalmente escribe CSV incremental.
"""
self._save_preferences()
root = self.view.get_root_path()
# Tipos de Documento (multi-doc)
doc_types = []
@@ -493,7 +595,16 @@ class AppController:
keywords_str = self.view.get_keywords()
fields_str = self.view.get_fields()
structure_mode = self.view.get_process_mode()
retry_on_empty = self.view.get_retry_empty_enabled()
# Filtro de radicados
radicado_filter = None
try:
if self.view.get_radicado_filter_enabled():
radicado_filter = self.view.get_radicado_filter_list() or None
except Exception:
radicado_filter = None
if structure_mode == "general":
# En modo general, si está vacío o es el default, procesar todo (None)
# Si el usuario escribió algo específico, usarlo.
@@ -607,6 +718,8 @@ class AppController:
csv_output_path=csv_path,
tiff_page_range=tiff_page_range,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
)
else:
resultados = self.service.process_root_with_field_selection(
@@ -617,6 +730,8 @@ class AppController:
csv_output_path=csv_path,
tiff_page_range=tiff_page_range,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
)
# Si requiere selección de campos abrir diálogo; de lo contrario, mostrar resultados
@@ -801,6 +916,8 @@ class AppController:
alias_map=alias_map,
tiff_page_range=tiff_page_range,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
)
else:
res = self.service.process_root_with_field_selection(
@@ -814,6 +931,8 @@ class AppController:
alias_map=alias_map,
tiff_page_range=tiff_page_range,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
)
self.view.append_output_safe(json.dumps(res, ensure_ascii=False, indent=2))
except Exception as e2:
+23
View File
@@ -11,6 +11,12 @@ from typing import Dict, List, Callable, Optional
import customtkinter as ctk
import threading
import tkinter.messagebox as mb
from application.app_prefs import get_allowed_document_types
DOCUMENT_TYPE_HINT_ALIASES = {
"CC", "TI", "CE", "RC", "PA", "PPT", "PEP", "EPT", "NUIP",
"PT", "PAS", "PASS",
}
class FieldSelectorDialog(ctk.CTkToplevel):
@@ -99,6 +105,7 @@ class FieldSelectorDialog(ctk.CTkToplevel):
except Exception:
pass
alias_entry.grid(row=0, column=1, padx=(12, 0))
self._add_document_type_hint(row, path)
try:
alias_entry.bind("<KeyRelease>", lambda _e: self._validate_aliases())
except Exception:
@@ -332,6 +339,7 @@ class FieldSelectorDialog(ctk.CTkToplevel):
except Exception:
pass
alias_entry.grid(row=0, column=1, padx=(12, 0))
self._add_document_type_hint(row, path)
try:
alias_entry.bind("<KeyRelease>", lambda _e: self._validate_aliases())
except Exception:
@@ -361,3 +369,18 @@ class FieldSelectorDialog(ctk.CTkToplevel):
except Exception:
pass
def _add_document_type_hint(self, row: ctk.CTkFrame, path: str) -> None:
"""Muestra una ayuda breve bajo claves tipo CC, TI, CE, PAS, etc."""
key_tail = str(path or "").split("/")[-1].strip().upper()
valid_hint_keys = set(get_allowed_document_types()) | DOCUMENT_TYPE_HINT_ALIASES
if key_tail not in valid_hint_keys:
return
hint = ctk.CTkLabel(
row,
text="Si a este campo le pones el alias 'documento_forzate', se guardará con la sigla exacta detectada, por ejemplo 'CC 123456789' o 'EPT 123456789'.",
anchor="w",
justify="left",
)
hint.grid(row=1, column=0, columnspan=2, sticky="w", padx=(28, 0), pady=(2, 0))
+124 -2
View File
@@ -1,6 +1,7 @@
"""Pestaña Procesar Documentos de la aplicación GUI."""
import customtkinter as ctk
from gui.styles import AppStyles
from application.app_prefs import get_allowed_document_types
class MainProcessingTab:
@@ -112,7 +113,47 @@ class MainProcessingTab:
font=self.fonts["ui"]
)
self.radio_general_main.pack(side="left")
# Filtro por Radicados
self.filter_enabled_var = ctk.BooleanVar(value=False)
self.filter_check = ctk.CTkCheckBox(
self.frame_paths,
text="Procesar solo radicados seleccionados",
variable=self.filter_enabled_var,
font=self.fonts["ui"],
command=self._on_filter_toggle
)
self.filter_check.grid(row=4, column=0, columnspan=3, padx=15, pady=(4, 0), sticky="w")
self.filter_frame = ctk.CTkFrame(self.frame_paths, fg_color="transparent")
self.filter_frame.grid(row=5, column=0, columnspan=3, padx=15, pady=(2, 6), sticky="ew")
self.filter_frame.grid_columnconfigure(0, weight=1)
filter_hint = ctk.CTkLabel(
self.filter_frame,
text="Ingresa los radicados a procesar (uno por línea o separados por coma):",
font=self.fonts["ui"],
text_color=self.colors["text_muted"]
)
filter_hint.grid(row=0, column=0, sticky="w", pady=(0, 2))
self.filter_textbox = ctk.CTkTextbox(
self.filter_frame,
height=80,
font=self.fonts["mono"],
)
self.filter_textbox.grid(row=1, column=0, sticky="ew")
# Ocultar hasta que se active el checkbox
self.filter_frame.grid_remove()
def _on_filter_toggle(self):
"""Muestra u oculta el área de texto del filtro."""
if self.filter_enabled_var.get():
self.filter_frame.grid()
else:
self.filter_frame.grid_remove()
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)
@@ -139,6 +180,15 @@ class MainProcessingTab:
font=self.fonts["section"]
)
self.doc_types_label.grid(row=0, column=0, sticky="w")
allowed_types_text = ", ".join(get_allowed_document_types())
self.doc_types_hint = ctk.CTkLabel(
self.doc_types_header,
text=f"Tipos válidos para documento_forzate: {allowed_types_text}. NIT no aplica.",
font=self.fonts["ui"],
text_color=self.colors["text_muted"],
)
self.doc_types_hint.grid(row=1, column=0, sticky="w", pady=(2, 0))
self.add_doc_type_btn = ctk.CTkButton(
self.doc_types_header,
@@ -147,7 +197,7 @@ class MainProcessingTab:
width=120,
height=28
)
self.add_doc_type_btn.grid(row=0, column=1, sticky="e")
self.add_doc_type_btn.grid(row=0, column=1, rowspan=2, sticky="e")
self.add_doc_type_btn.configure(command=self._add_doc_type_row)
# Lista de Tipos
@@ -161,6 +211,16 @@ class MainProcessingTab:
# 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.frame_advanced.grid_columnconfigure(0, weight=1)
self.retry_empty_var = ctk.BooleanVar(value=False)
self.retry_empty_check = ctk.CTkCheckBox(
self.frame_advanced,
text="Reintentar si la fila sale vacía (máx. 2 intentos)",
variable=self.retry_empty_var,
font=self.fonts["ui"]
)
self.retry_empty_check.grid(row=0, column=0, columnspan=4, padx=5, pady=(0, 6), sticky="w")
self.tiff_start_label = ctk.CTkLabel(
self.frame_advanced,
@@ -323,6 +383,16 @@ class MainProcessingTab:
return v if v in ("radicados", "general") else "radicados"
except Exception:
return "radicados"
def set_process_mode(self, mode: str) -> None:
"""Establece el modo de procesamiento y refresca la UI."""
try:
normalized = (mode or "").strip().lower()
self.mode_var.set(normalized if normalized in ("radicados", "general") else "radicados")
self.update_ui_for_mode()
except Exception:
self.mode_var.set("radicados")
self.update_ui_for_mode()
def get_keywords(self) -> str:
"""Devuelve los keywords."""
@@ -347,6 +417,50 @@ class MainProcessingTab:
return int(value) if value else 0
except Exception:
return 0
def get_retry_empty_enabled(self) -> bool:
"""Indica si se debe reintentar cuando la fila solo trae el radicado."""
try:
return bool(self.retry_empty_var.get())
except Exception:
return False
def set_retry_empty_enabled(self, enabled: bool) -> None:
"""Establece el reintento automático por fila vacía."""
self.retry_empty_var.set(bool(enabled))
def get_radicado_filter_enabled(self) -> bool:
"""Indica si el filtro de radicados está activo."""
try:
return bool(self.filter_enabled_var.get())
except Exception:
return False
def get_radicado_filter_list(self) -> list[str]:
"""Devuelve la lista de radicados del filtro (sin blancos ni duplicados)."""
try:
raw = self.filter_textbox.get("1.0", "end")
items = []
for part in raw.replace(",", "\n").splitlines():
s = part.strip()
if s:
items.append(s)
return list(dict.fromkeys(items)) # preserva orden, elimina duplicados
except Exception:
return []
def set_radicado_filter_enabled(self, enabled: bool) -> None:
"""Activa o desactiva el filtro y refresca la UI."""
self.filter_enabled_var.set(bool(enabled))
self._on_filter_toggle()
def set_radicado_filter_list(self, radicados: list[str]) -> None:
"""Carga una lista de radicados en el área de texto del filtro."""
try:
self.filter_textbox.delete("1.0", "end")
self.filter_textbox.insert("1.0", "\n".join(radicados))
except Exception:
pass
def show_tiff_range_controls(self, show: bool) -> None:
"""Muestra u oculta los controles de rango TIFF."""
@@ -398,6 +512,11 @@ class MainProcessingTab:
if not seen:
return False, "Debe haber al menos un tipo de documento."
return True, None
def set_allowed_document_types_hint(self, allowed_types: list[str]) -> None:
"""Actualiza el texto de ayuda de tipos válidos."""
text = ", ".join(allowed_types)
self.doc_types_hint.configure(text=f"Tipos válidos para documento_forzate: {text}. NIT no aplica.")
def update_ui_for_mode(self):
"""Actualiza la visibilidad de componentes según el modo seleccionado."""
@@ -445,6 +564,9 @@ class MainProcessingTab:
self.csv_button.configure(state=state)
self.csv_entry.configure(state=state)
self.add_doc_type_btn.configure(state=state)
self.retry_empty_check.configure(state=state)
self.filter_check.configure(state=state)
self.filter_textbox.configure(state=state)
for row in self._doc_type_rows:
row["name_entry"].configure(state=state)
+50 -6
View File
@@ -1,6 +1,7 @@
"""Pestaña de Configuración de la aplicación GUI."""
import customtkinter as ctk
from gui.styles import AppStyles
from application.app_prefs import get_allowed_document_types, normalize_document_types
class SettingsTab:
@@ -20,6 +21,7 @@ class SettingsTab:
self._setup_layout()
self._create_api_section()
self._create_processing_section()
self._create_document_types_section()
self._create_save_button()
def _setup_layout(self):
@@ -55,18 +57,18 @@ class SettingsTab:
self.combo_model = ctk.CTkComboBox(
self.frame_api,
values=["5.1", "4.1", "4.1 mini", "4.1 nano"],
values=["5.4", "5.4-mini", "5.4-nano", "5.2", "4.1-mini"],
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")
self.combo_model.set("5.4")
# 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"
"ℹ 5.4: Modelo predeterminado\n"
"ℹ 5.4-mini / 4.1-mini: opciones más rápidas\n"
"ℹ 5.4-nano: opción más económica"
)
lbl_help = ctk.CTkLabel(
self.frame_api,
@@ -119,6 +121,36 @@ class SettingsTab:
text_color="orange"
)
lbl_conc_warn.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 10), sticky="w")
def _create_document_types_section(self):
"""Crea la sección editable de tipos documentales permitidos."""
self.frame_doc_types = ctk.CTkFrame(self.settings_container)
self.frame_doc_types.grid(row=2, column=0, sticky="ew", padx=5, pady=5)
self.frame_doc_types.grid_columnconfigure(0, weight=1)
lbl_title = ctk.CTkLabel(
self.frame_doc_types,
text="Tipos de Documento Permitidos",
font=self.fonts["section"]
)
lbl_title.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 4))
lbl_help = ctk.CTkLabel(
self.frame_doc_types,
text="Escríbelos separados por comas. Se usarán en documento_forzate. NIT se ignorará automáticamente.",
font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
justify="left"
)
lbl_help.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 6))
self.allowed_doc_types_entry = ctk.CTkEntry(
self.frame_doc_types,
font=self.fonts["ui"],
placeholder_text="CC, TI, CE, RC, PA, PPT, PEP, EPT, NUIP"
)
self.allowed_doc_types_entry.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 10))
self.set_allowed_document_types(list(get_allowed_document_types()))
def _create_save_button(self):
"""Crea el botón de guardar configuración."""
@@ -128,7 +160,7 @@ class SettingsTab:
font=self.fonts["ui"],
height=35
)
self.btn_save_settings.grid(row=2, column=0, padx=15, pady=15, sticky="ew")
self.btn_save_settings.grid(row=3, column=0, padx=15, pady=15, sticky="ew")
# --- API Pública ---
@@ -154,10 +186,22 @@ class SettingsTab:
def set_concurrency_limit(self, limit: int) -> None:
"""Establece el límite de concurrencia."""
self.concurrency_var.set(str(limit))
def get_allowed_document_types(self) -> list[str]:
"""Devuelve la lista editable de tipos documentales válidos."""
raw = self.allowed_doc_types_entry.get().strip()
values = [part.strip() for part in raw.split(",")]
return normalize_document_types(values)
def set_allowed_document_types(self, values: list[str]) -> None:
"""Establece la lista de tipos documentales válidos."""
self.allowed_doc_types_entry.delete(0, "end")
self.allowed_doc_types_entry.insert(0, ", ".join(normalize_document_types(values)))
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.allowed_doc_types_entry.configure(state=state)
self.btn_save_settings.configure(state=state)
+24
View File
@@ -109,6 +109,12 @@ class InvoiceAppView(ctk.CTk):
def set_concurrency_limit(self, limit: int) -> None:
self.settings_tab.set_concurrency_limit(limit)
def get_allowed_document_types(self) -> list[str]:
return self.settings_tab.get_allowed_document_types()
def set_allowed_document_types(self, values: list[str]) -> None:
self.settings_tab.set_allowed_document_types(values)
def set_on_run_preclassification(self, callback):
self.preclass_tab.set_on_run(callback)
@@ -177,6 +183,9 @@ class InvoiceAppView(ctk.CTk):
"""Devuelve el modo de procesamiento: 'radicados' o 'general'."""
return self.main_tab.get_process_mode()
def set_process_mode(self, mode: str) -> None:
self.main_tab.set_process_mode(mode)
def get_preclass_root_path(self) -> str:
return self.preclass_tab.get_root_path()
@@ -201,6 +210,18 @@ class InvoiceAppView(ctk.CTk):
def get_csv_path(self) -> str:
return self.main_tab.get_csv_path()
def get_retry_empty_enabled(self) -> bool:
return self.main_tab.get_retry_empty_enabled()
def set_retry_empty_enabled(self, enabled: bool) -> None:
self.main_tab.set_retry_empty_enabled(enabled)
def get_radicado_filter_enabled(self) -> bool:
return self.main_tab.get_radicado_filter_enabled()
def get_radicado_filter_list(self) -> list[str]:
return self.main_tab.get_radicado_filter_list()
def show_tiff_range_controls(self, show: bool) -> None:
self.main_tab.show_tiff_range_controls(show)
@@ -327,5 +348,8 @@ class InvoiceAppView(ctk.CTk):
def set_doc_types(self, doc_types: list[dict]) -> None:
self.main_tab.set_doc_types(doc_types)
def set_allowed_document_types_hint(self, allowed_types: list[str]) -> None:
self.main_tab.set_allowed_document_types_hint(allowed_types)
def validate_doc_types(self) -> tuple[bool, str | None]:
return self.main_tab.validate_doc_types()