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:
@@ -0,0 +1,54 @@
|
||||
"""Persistencia simple de preferencias de la GUI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from .config import DEFAULT_ALLOWED_DOCUMENT_TYPES
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PREFS_FILE = Path(__file__).resolve().parent.parent / "app_prefs.json"
|
||||
|
||||
|
||||
def load_app_prefs() -> Dict[str, Any]:
|
||||
"""Carga preferencias desde disco si existen."""
|
||||
try:
|
||||
if not PREFS_FILE.exists():
|
||||
return {}
|
||||
raw = PREFS_FILE.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
logger.exception("No se pudieron cargar las preferencias de la app")
|
||||
return {}
|
||||
|
||||
|
||||
def save_app_prefs(data: Dict[str, Any]) -> None:
|
||||
"""Guarda preferencias de la app en JSON."""
|
||||
try:
|
||||
PREFS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
logger.exception("No se pudieron guardar las preferencias de la app")
|
||||
|
||||
|
||||
def normalize_document_types(values: Any) -> list[str]:
|
||||
"""Normaliza una lista editable de tipos documentales."""
|
||||
raw_values = values if isinstance(values, list) else []
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_values:
|
||||
text = str(item or "").strip().upper()
|
||||
if not text or text == "NIT" or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
normalized.append(text)
|
||||
return normalized or list(DEFAULT_ALLOWED_DOCUMENT_TYPES)
|
||||
|
||||
|
||||
def get_allowed_document_types() -> tuple[str, ...]:
|
||||
"""Devuelve la lista persistida de tipos documentales válidos."""
|
||||
prefs = load_app_prefs()
|
||||
return tuple(normalize_document_types(prefs.get("allowed_document_types")))
|
||||
+244
-21
@@ -18,11 +18,23 @@ from application.costs import compute_cost_from_usage, accumulate_usage
|
||||
from application.file_discovery import get_sorted_radicados, get_files_as_radicados
|
||||
from application.csv_manager import CSVManager
|
||||
from application.config import settings
|
||||
from application.app_prefs import get_allowed_document_types
|
||||
from core.utils import collect_radicado_docs, find_first_matching_file, collect_documents_flat
|
||||
from core.csv_utils import rows_from_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DOCUMENT_FORCE_TARGETS = {
|
||||
"tipo de documento",
|
||||
"documento_forzate",
|
||||
}
|
||||
|
||||
DOCUMENT_TYPE_HINT_ALIASES = {
|
||||
"PT",
|
||||
"PAS",
|
||||
"PASS",
|
||||
}
|
||||
|
||||
class BatchProcessor:
|
||||
def __init__(self, service: 'InvoiceProcessingService'):
|
||||
"""
|
||||
@@ -51,6 +63,8 @@ class BatchProcessor:
|
||||
alias_map: Optional[Dict[str, str]] = None,
|
||||
tiff_page_range: Optional[Tuple[int, int]] = None,
|
||||
structure_mode: str = "radicados",
|
||||
retry_on_empty: bool = False,
|
||||
radicado_filter: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Procesa radicados con selección de campos y CSV incremental.
|
||||
|
||||
@@ -77,7 +91,9 @@ class BatchProcessor:
|
||||
on_progress=on_progress,
|
||||
on_usage=on_usage,
|
||||
on_error_retry=on_error_retry,
|
||||
structure_mode=structure_mode
|
||||
structure_mode=structure_mode,
|
||||
retry_on_empty=retry_on_empty,
|
||||
radicado_filter=radicado_filter,
|
||||
))
|
||||
|
||||
# 3. Procesamiento Single-doc (Retrocompatibilidad)
|
||||
@@ -94,7 +110,9 @@ class BatchProcessor:
|
||||
tiff_page_range=tiff_page_range,
|
||||
on_progress=on_progress,
|
||||
on_usage=on_usage,
|
||||
structure_mode=structure_mode
|
||||
structure_mode=structure_mode,
|
||||
retry_on_empty=retry_on_empty,
|
||||
radicado_filter=radicado_filter,
|
||||
)
|
||||
else:
|
||||
# Si hay fields, es procesamiento masivo -> ASYNC
|
||||
@@ -110,7 +128,9 @@ class BatchProcessor:
|
||||
on_progress=on_progress,
|
||||
on_usage=on_usage,
|
||||
on_error_retry=on_error_retry,
|
||||
structure_mode=structure_mode
|
||||
structure_mode=structure_mode,
|
||||
retry_on_empty=retry_on_empty,
|
||||
radicado_filter=radicado_filter,
|
||||
))
|
||||
|
||||
def _prepare_multidoc_schemas(self, root_path, doc_types, instruction, tiff_page_range, structure_mode):
|
||||
@@ -131,9 +151,11 @@ class BatchProcessor:
|
||||
async def _process_multidoc_async(
|
||||
self, root_path, doc_types, fields, csv_output_path,
|
||||
csv_header_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None,
|
||||
structure_mode="radicados"
|
||||
structure_mode="radicados", retry_on_empty=False, radicado_filter=None
|
||||
):
|
||||
csv_header = self._prepare_csv_header(fields, alias_map, csv_header_override)
|
||||
effective_fields = self._expand_document_force_fields(fields or [], alias_map)
|
||||
effective_alias_map = self._expand_document_force_alias_map(alias_map, effective_fields)
|
||||
csv_header = self._prepare_csv_header(effective_fields, effective_alias_map, csv_header_override)
|
||||
|
||||
if structure_mode == "general":
|
||||
radicados = get_files_as_radicados(root_path)
|
||||
@@ -143,6 +165,15 @@ class BatchProcessor:
|
||||
if not radicados and not os.path.isdir(root_path):
|
||||
raise ValueError(f"La ruta raíz no es un directorio: {root_path}")
|
||||
|
||||
# Aplicar filtro de radicados si está activo
|
||||
if radicado_filter:
|
||||
filter_set = {r.strip() for r in radicado_filter if r.strip()}
|
||||
if structure_mode == "general":
|
||||
radicados = [r for r in radicados if os.path.splitext(r)[0] in filter_set]
|
||||
else:
|
||||
radicados = [r for r in radicados if r in filter_set]
|
||||
logger.info(f"Filtro activo: {len(radicados)} radicados seleccionados de {len(filter_set)} indicados")
|
||||
|
||||
total = len(radicados)
|
||||
prof = get_profile('invoice')
|
||||
|
||||
@@ -161,7 +192,10 @@ class BatchProcessor:
|
||||
async def process_radicado(idx, rad):
|
||||
nonlocal completed_count
|
||||
async with sem:
|
||||
attempts = 0
|
||||
while True: # Bucle para permitir reintentos tras pausa
|
||||
attempts += 1
|
||||
should_retry_empty = False
|
||||
await self._pause_event.wait()
|
||||
if self._stop_requested:
|
||||
return {"radicado": rad, "status": "stopped"}
|
||||
@@ -209,7 +243,7 @@ class BatchProcessor:
|
||||
continue
|
||||
|
||||
# Extraer
|
||||
selected_paths_for_type = [p.split('/', 1)[1] for p in (fields or []) if p.startswith(f"{tname}/")]
|
||||
selected_paths_for_type = [p.split('/', 1)[1] for p in effective_fields if p.startswith(f"{tname}/")]
|
||||
try:
|
||||
prompt_t = PromptBuilder.build_for_type(tname, selected_paths_for_type, {}, profile_name=prof.get('name', 'invoice'))
|
||||
except Exception:
|
||||
@@ -220,6 +254,7 @@ class BatchProcessor:
|
||||
self._accumulate_usage(agg_extract_usage, usage_ext)
|
||||
|
||||
# Normalizar (CPU bound, mover a thread)
|
||||
raw_data = data if isinstance(data, dict) else {"data": data}
|
||||
if isinstance(data, dict):
|
||||
data['radicado'] = rad_id
|
||||
else:
|
||||
@@ -238,12 +273,28 @@ class BatchProcessor:
|
||||
if isinstance(norm, dict):
|
||||
n_usage = norm.get("_usage")
|
||||
normalized_obj = norm.get("normalized") or data
|
||||
|
||||
if isinstance(normalized_obj, dict):
|
||||
normalized_obj = self._inject_document_force_fields_from_raw(
|
||||
normalized_obj,
|
||||
raw_data,
|
||||
selected_paths_for_type,
|
||||
)
|
||||
|
||||
self._accumulate_usage(agg_normalize_usage, n_usage)
|
||||
fused_data[tname] = normalized_obj or {}
|
||||
fused_data["_source_pdf"] = match
|
||||
|
||||
if retry_on_empty and self._is_effectively_empty_output(fused_data, effective_fields, effective_alias_map):
|
||||
if attempts < 2:
|
||||
logger.info(f"[{rad_id}] Resultado vacío; reintentando extracción ({attempts + 1}/2)", extra={"radicado": rad_id})
|
||||
should_retry_empty = True
|
||||
else:
|
||||
logger.info(f"[{rad_id}] Resultado vacío tras 2 intentos; se conserva el resultado.", extra={"radicado": rad_id})
|
||||
|
||||
# Report usage
|
||||
if should_retry_empty:
|
||||
continue
|
||||
self._report_usage(on_usage, rad_id, "extract", agg_extract_usage)
|
||||
self._report_usage(on_usage, rad_id, "normalize", agg_normalize_usage)
|
||||
|
||||
@@ -252,8 +303,8 @@ class BatchProcessor:
|
||||
async with csv_lock:
|
||||
await asyncio.to_thread(
|
||||
self._write_csv_multidoc,
|
||||
csv_output_path, fused_data, fields, doc_types,
|
||||
prof, csv_header, alias_map, rad_id
|
||||
csv_output_path, fused_data, effective_fields, doc_types,
|
||||
prof, csv_header, effective_alias_map, rad_id
|
||||
)
|
||||
|
||||
return fused_data
|
||||
@@ -280,9 +331,11 @@ class BatchProcessor:
|
||||
return self._handle_error_async(rad, e, csv_output_path, csv_header)
|
||||
finally:
|
||||
async with progress_lock:
|
||||
completed_count += 1
|
||||
self._notify_progress(on_progress, rad, completed_count, total)
|
||||
break # Salir del bucle True si no hubo RateLimitError o se manejó
|
||||
if not should_retry_empty:
|
||||
completed_count += 1
|
||||
self._notify_progress(on_progress, rad, completed_count, total)
|
||||
if not should_retry_empty:
|
||||
break # Salir del bucle True si no hubo RateLimitError o se manejó
|
||||
|
||||
tasks = [process_radicado(i, r) for i, r in enumerate(radicados, start=1)]
|
||||
resultados = await asyncio.gather(*tasks)
|
||||
@@ -291,12 +344,18 @@ class BatchProcessor:
|
||||
def _process_singledoc(
|
||||
self, root_path, keywords, fields, csv_output_path,
|
||||
csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None,
|
||||
structure_mode="radicados"
|
||||
structure_mode="radicados", retry_on_empty=False, radicado_filter=None
|
||||
):
|
||||
if structure_mode == "general":
|
||||
entries = collect_documents_flat(root_path, keywords)
|
||||
else:
|
||||
entries = collect_radicado_docs(root_path, keywords or [])
|
||||
|
||||
# Aplicar filtro de radicados si está activo
|
||||
if radicado_filter:
|
||||
filter_set = {r.strip() for r in radicado_filter if r.strip()}
|
||||
entries = [e for e in entries if e.get("radicado") in filter_set]
|
||||
logger.info(f"Filtro activo: {len(entries)} entradas seleccionadas de {len(filter_set)} indicadas")
|
||||
|
||||
if not entries:
|
||||
logger.info("No se encontraron radicados con PDFs que coincidan.")
|
||||
@@ -321,27 +380,35 @@ class BatchProcessor:
|
||||
# Fallback a procesamiento secuencial/async si se llama directamente
|
||||
return asyncio.run(self._process_singledoc_async(
|
||||
root_path, keywords, fields, csv_output_path, csv_header_override,
|
||||
instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry, structure_mode
|
||||
instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry, structure_mode, retry_on_empty,
|
||||
radicado_filter=radicado_filter,
|
||||
))
|
||||
|
||||
async def _process_singledoc_async(
|
||||
self, root_path, keywords, fields, csv_output_path,
|
||||
csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None,
|
||||
structure_mode="radicados"
|
||||
structure_mode="radicados", retry_on_empty=False, radicado_filter=None
|
||||
):
|
||||
# Recolección de archivos (IO bloqueante)
|
||||
if structure_mode == "general":
|
||||
entries = await asyncio.to_thread(collect_documents_flat, root_path, keywords)
|
||||
else:
|
||||
entries = await asyncio.to_thread(collect_radicado_docs, root_path, keywords or [])
|
||||
|
||||
# Aplicar filtro de radicados si está activo
|
||||
if radicado_filter:
|
||||
filter_set = {r.strip() for r in radicado_filter if r.strip()}
|
||||
entries = [e for e in entries if e.get("radicado") in filter_set]
|
||||
logger.info(f"Filtro activo: {len(entries)} entradas seleccionadas de {len(filter_set)} indicadas")
|
||||
|
||||
if not entries:
|
||||
logger.info("No se encontraron radicados con PDFs que coincidan.")
|
||||
return []
|
||||
|
||||
selected_fields = fields or []
|
||||
selected_fields = self._expand_document_force_fields(fields or [], alias_map)
|
||||
effective_alias_map = self._expand_document_force_alias_map(alias_map, selected_fields)
|
||||
prompt = PromptBuilder.build(selected_fields, {})
|
||||
csv_header = self._prepare_csv_header(selected_fields, alias_map, csv_header_override)
|
||||
csv_header = self._prepare_csv_header(selected_fields, effective_alias_map, csv_header_override)
|
||||
|
||||
prof = get_profile('invoice')
|
||||
total = len(entries)
|
||||
@@ -358,7 +425,10 @@ class BatchProcessor:
|
||||
async def process_entry(idx, entry):
|
||||
nonlocal completed_count
|
||||
async with sem:
|
||||
attempts = 0
|
||||
while True: # Bucle para permitir reintentos tras pausa
|
||||
attempts += 1
|
||||
should_retry_empty = False
|
||||
await self._pause_event.wait()
|
||||
if self._stop_requested:
|
||||
return {"radicado": entry['radicado'], "status": "stopped"}
|
||||
@@ -370,6 +440,7 @@ class BatchProcessor:
|
||||
data, usage_ext = await self.service.extract_full_from_pdf_async(entry["path"], instruction=prompt, tiff_page_range=tiff_page_range)
|
||||
self._report_usage(on_usage, entry['radicado'], "extract", usage_ext)
|
||||
|
||||
raw_data = data if isinstance(data, dict) else {"data": data}
|
||||
if isinstance(data, dict):
|
||||
data['radicado'] = entry['radicado']
|
||||
else:
|
||||
@@ -388,15 +459,28 @@ class BatchProcessor:
|
||||
|
||||
normalized_obj = norm.get("normalized")
|
||||
if isinstance(normalized_obj, dict):
|
||||
data = normalized_obj
|
||||
data = self._inject_document_force_fields_from_raw(
|
||||
normalized_obj,
|
||||
raw_data,
|
||||
selected_fields,
|
||||
)
|
||||
data['radicado'] = entry['radicado']
|
||||
data["_source_pdf"] = entry["path"]
|
||||
|
||||
if retry_on_empty and self._is_effectively_empty_output(data, selected_fields, effective_alias_map):
|
||||
if attempts < 2:
|
||||
logger.info(f"[{entry['radicado']}] Resultado vacío; reintentando extracción ({attempts + 1}/2)", extra={"radicado": entry['radicado'], "pdf": entry['path']})
|
||||
should_retry_empty = True
|
||||
else:
|
||||
logger.info(f"[{entry['radicado']}] Resultado vacío tras 2 intentos; se conserva el resultado.", extra={"radicado": entry['radicado'], "pdf": entry['path']})
|
||||
|
||||
if should_retry_empty:
|
||||
continue
|
||||
if csv_output_path:
|
||||
async with csv_lock:
|
||||
await asyncio.to_thread(
|
||||
self._write_csv_singledoc,
|
||||
csv_output_path, data, selected_fields, prof, csv_header, alias_map, entry['radicado']
|
||||
csv_output_path, data, selected_fields, prof, csv_header, effective_alias_map, entry['radicado']
|
||||
)
|
||||
|
||||
return data
|
||||
@@ -423,9 +507,11 @@ class BatchProcessor:
|
||||
return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path'])
|
||||
finally:
|
||||
async with progress_lock:
|
||||
completed_count += 1
|
||||
self._notify_progress(on_progress, entry['radicado'], completed_count, total)
|
||||
break # Salir del bucle si no hubo RateLimitError o se manejó
|
||||
if not should_retry_empty:
|
||||
completed_count += 1
|
||||
self._notify_progress(on_progress, entry['radicado'], completed_count, total)
|
||||
if not should_retry_empty:
|
||||
break # Salir del bucle si no hubo RateLimitError o se manejó
|
||||
|
||||
tasks = [process_entry(i, e) for i, e in enumerate(entries, start=1)]
|
||||
resultados = await asyncio.gather(*tasks)
|
||||
@@ -443,6 +529,65 @@ class BatchProcessor:
|
||||
expanded[sub] = alias_value
|
||||
return expanded
|
||||
|
||||
def _detect_document_force_target(self, alias_map: Optional[Dict[str, str]]) -> Optional[str]:
|
||||
if not alias_map:
|
||||
return None
|
||||
for alias_value in alias_map.values():
|
||||
target = str(alias_value or "").strip()
|
||||
if target.lower() in DOCUMENT_FORCE_TARGETS:
|
||||
return target
|
||||
return None
|
||||
|
||||
def _expand_document_force_fields(self, fields: List[str], alias_map: Optional[Dict[str, str]]) -> List[str]:
|
||||
"""Si existe `documento_forzate`, incluye todos los tipos documentales válidos del mismo nivel."""
|
||||
target = self._detect_document_force_target(alias_map)
|
||||
if not target:
|
||||
return list(fields or [])
|
||||
|
||||
allowed_types = list(get_allowed_document_types())
|
||||
expanded = list(fields or [])
|
||||
seen = set(expanded)
|
||||
|
||||
for orig_key, alias_value in (alias_map or {}).items():
|
||||
if str(alias_value or "").strip().lower() not in DOCUMENT_FORCE_TARGETS:
|
||||
continue
|
||||
|
||||
base = str(orig_key or "").strip()
|
||||
if not base:
|
||||
continue
|
||||
|
||||
if "/" in base:
|
||||
prefix = base.rsplit("/", 1)[0]
|
||||
candidates = [f"{prefix}/{doc_type}" for doc_type in allowed_types]
|
||||
else:
|
||||
candidates = allowed_types
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate not in seen:
|
||||
expanded.append(candidate)
|
||||
seen.add(candidate)
|
||||
|
||||
return expanded
|
||||
|
||||
def _expand_document_force_alias_map(self, alias_map: Optional[Dict[str, str]], fields: List[str]) -> Dict[str, str]:
|
||||
"""Mapea todos los tipos documentales válidos al mismo alias especial."""
|
||||
expanded = dict(alias_map or {})
|
||||
target = self._detect_document_force_target(expanded)
|
||||
if not target:
|
||||
return expanded
|
||||
|
||||
allowed_types = list(get_allowed_document_types())
|
||||
for field in fields or []:
|
||||
field_str = str(field or "").strip()
|
||||
if not field_str:
|
||||
continue
|
||||
key_tail = field_str.split("/")[-1].upper()
|
||||
if key_tail not in allowed_types:
|
||||
continue
|
||||
expanded[field_str] = target
|
||||
|
||||
return expanded
|
||||
|
||||
def _prepare_csv_header(self, fields, alias_map, override):
|
||||
if override:
|
||||
return override
|
||||
@@ -460,6 +605,62 @@ class BatchProcessor:
|
||||
seen.add(h)
|
||||
return final_header
|
||||
|
||||
def _is_effectively_empty_output(self, data: Dict[str, Any], fields: List[str], alias_map: Optional[Dict[str, str]]) -> bool:
|
||||
"""Verdadero cuando la salida final solo conserva el radicado y no trae datos útiles."""
|
||||
try:
|
||||
rows, _header = rows_from_data(data, allowed_paths=fields)
|
||||
final_rows = self._apply_alias_to_rows(rows, alias_map)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not final_rows:
|
||||
return True
|
||||
|
||||
for row in final_rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for key, value in row.items():
|
||||
if str(key).strip().lower() == "radicado":
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
return False
|
||||
return True
|
||||
|
||||
def _inject_document_force_fields_from_raw(self, normalized_obj: Dict[str, Any], raw_obj: Dict[str, Any], selected_fields: List[str]) -> Dict[str, Any]:
|
||||
"""Inyecta campos documentales desde la extracción cruda si el normalizador los omitió."""
|
||||
result = dict(normalized_obj or {})
|
||||
selected_keys = {str(p or "").split("/")[-1].strip().upper() for p in (selected_fields or [])}
|
||||
candidates = self._collect_document_force_candidates(raw_obj)
|
||||
|
||||
for dtype, value in candidates.items():
|
||||
if selected_keys and dtype not in selected_keys:
|
||||
continue
|
||||
if dtype not in result or result.get(dtype) in (None, ""):
|
||||
result[dtype] = value
|
||||
return result
|
||||
|
||||
def _collect_document_force_candidates(self, obj: Any) -> Dict[str, Any]:
|
||||
"""Recorre el JSON crudo buscando claves tipo CC/TI/EPT/PT con sus valores."""
|
||||
found: Dict[str, Any] = {}
|
||||
valid_keys = set(get_allowed_document_types()) | DOCUMENT_TYPE_HINT_ALIASES
|
||||
|
||||
def walk(node: Any) -> None:
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
key_tail = str(key or "").split("/")[-1].strip().upper()
|
||||
if key_tail in valid_keys and value not in (None, ""):
|
||||
found[key_tail] = value
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(obj)
|
||||
return found
|
||||
|
||||
def _notify_progress(self, callback, rad, idx, total):
|
||||
if callback:
|
||||
try:
|
||||
@@ -529,6 +730,7 @@ class BatchProcessor:
|
||||
new_r = {}
|
||||
for k, v in r.items():
|
||||
new_k = alias_map2.get(k, k)
|
||||
v = self._format_document_type_value(k, new_k, v)
|
||||
|
||||
if new_k in new_r:
|
||||
# Lógica de fusión solicitada:
|
||||
@@ -556,6 +758,27 @@ class BatchProcessor:
|
||||
aliased_rows.append(r)
|
||||
return aliased_rows
|
||||
|
||||
def _format_document_type_value(self, original_key, aliased_key, value):
|
||||
"""Compone 'CC 123456' cuando una clave de tipo documental se exporta como 'tipo de documento'."""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
document_type_keys = set(get_allowed_document_types())
|
||||
key_tail = str(original_key or "").split("/")[-1].strip().upper()
|
||||
target = str(aliased_key or "").strip().lower()
|
||||
if key_tail not in document_type_keys or target not in DOCUMENT_FORCE_TARGETS:
|
||||
return value
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return value
|
||||
|
||||
# Si ya viene prefijado con el mismo tipo, no duplicarlo.
|
||||
if text.upper().startswith(f"{key_tail} "):
|
||||
return text
|
||||
|
||||
return f"{key_tail} {text}"
|
||||
|
||||
def _handle_error_async(self, rad, e, csv_path, header, pdf_path=None):
|
||||
error_obj = {
|
||||
"radicado": rad,
|
||||
|
||||
+18
-1
@@ -26,7 +26,7 @@ class ApiSettings(BaseSettings):
|
||||
"""Parámetros del backend compatible con OpenAI/Ollama (usando os.getenv)."""
|
||||
base_url: str = os.getenv("EXTRACTOR_BASE_URL", "http://10.8.0.4:11434/v1")
|
||||
api_key: str = os.getenv("EXTRACTOR_API_KEY", "ollama")
|
||||
model: str = os.getenv("EXTRACTOR_MODEL", "gpt-4.1")
|
||||
model: str = os.getenv("EXTRACTOR_MODEL", "gpt-5.4")
|
||||
request_timeout_s: int = int(os.getenv("EXTRACTOR_TIMEOUT_S", 120))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@@ -65,3 +65,20 @@ class AppSettings(BaseSettings):
|
||||
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
# Tipos documentales válidos por defecto para el campo especial `documento_forzate`.
|
||||
# NIT se excluye explícitamente porque no debe tratarse como tipo de documento personal.
|
||||
DEFAULT_ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = (
|
||||
"CC",
|
||||
"TI",
|
||||
"CE",
|
||||
"RC",
|
||||
"PA",
|
||||
"PPT",
|
||||
"PEP",
|
||||
"EPT",
|
||||
"NUIP",
|
||||
)
|
||||
|
||||
# Alias de compatibilidad.
|
||||
ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = DEFAULT_ALLOWED_DOCUMENT_TYPES
|
||||
|
||||
+17
-17
@@ -7,36 +7,36 @@ from typing import Dict, Any, Optional
|
||||
|
||||
# Precios actualizados por 1M tokens (USD) - Enero 2026
|
||||
MODEL_PRICING = {
|
||||
"gpt-4.1": {
|
||||
"input": 2.00,
|
||||
"cached_input": 0.50,
|
||||
"output": 8.00,
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
"input": 0.40,
|
||||
"cached_input": 0.10,
|
||||
"output": 1.60,
|
||||
},
|
||||
"gpt-4.1-nano": {
|
||||
"input": 0.10,
|
||||
"cached_input": 0.03,
|
||||
"output": 0.40,
|
||||
},
|
||||
"gpt-5.1": {
|
||||
"gpt-5.2": {
|
||||
"input": 1.25,
|
||||
"cached_input": 0.13,
|
||||
"cached_input": 0.125,
|
||||
"output": 10.00,
|
||||
},
|
||||
"gpt-5.1-codex-mini": {
|
||||
"input": 0.25,
|
||||
"cached_input": 0.025,
|
||||
"output": 2.00,
|
||||
"gpt-5.4": {
|
||||
"input": 2.50,
|
||||
"cached_input": 0.25,
|
||||
"output": 15.00,
|
||||
},
|
||||
"gpt-5.4-mini": {
|
||||
"input": 0.75,
|
||||
"cached_input": 0.075,
|
||||
"output": 4.50,
|
||||
},
|
||||
"gpt-5.4-nano": {
|
||||
"input": 0.20,
|
||||
"cached_input": 0.02,
|
||||
"output": 0.80,
|
||||
},
|
||||
}
|
||||
|
||||
# Legacy mapping o fase por defecto (si no se especifica modelo)
|
||||
PRICING = {
|
||||
"extract": MODEL_PRICING["gpt-4.1"],
|
||||
"extract": MODEL_PRICING["gpt-5.4"],
|
||||
"normalize": MODEL_PRICING["gpt-4.1-mini"],
|
||||
}
|
||||
|
||||
|
||||
+15
-8
@@ -11,15 +11,22 @@ import json
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from .profiles import get_profile
|
||||
from .app_prefs import get_allowed_document_types
|
||||
|
||||
|
||||
_INSTRUCCIONES = [
|
||||
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
|
||||
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
|
||||
"No inventes claves nuevas ni cambies nombres existentes",
|
||||
"Si un campo no existe, usa null o [] según corresponda",
|
||||
"Ten especial cuidado en no confundir el número de autorización con el código",
|
||||
]
|
||||
def _build_instructions() -> List[str]:
|
||||
tipos_documento = ", ".join(get_allowed_document_types())
|
||||
return [
|
||||
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
|
||||
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
|
||||
"No inventes claves nuevas ni cambies nombres existentes",
|
||||
"Si un campo no existe, usa null o [] según corresponda",
|
||||
"Ten especial cuidado en no confundir el número de autorización con el código",
|
||||
f"Los únicos tipos de documento válidos para 'documento_forzate' son: {tipos_documento}. NIT no es válido para ese campo.",
|
||||
"Si entre los valores visibles aparece uno o varios tipos de documento válidos seguidos por su número (por ejemplo: 'CC 123456789' o 'EPT 123456789'), aunque no estén escritos con formato 'clave: valor', y existe el campo solicitado 'documento_forzate', guarda cada valor completo con su sigla exacta.",
|
||||
"Respeta la sigla exacta del documento encontrado. Ejemplo: si el documento dice 'EPT', devuelve 'EPT 123456789' y no lo conviertas a 'PT'.",
|
||||
"Si ves un campo llamado observacion o parecido tambien incluye ese valor en el JSON"
|
||||
]
|
||||
|
||||
_REGLAS_NOMENCLATURA = [
|
||||
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
|
||||
@@ -49,7 +56,7 @@ class PromptBuilder:
|
||||
meta.update(extra_meta)
|
||||
return {
|
||||
"meta": meta,
|
||||
"instrucciones": _INSTRUCCIONES,
|
||||
"instrucciones": _build_instructions(),
|
||||
"campos_requeridos": fields,
|
||||
"schema_referencia": {k: schema[k] for k in fields if k in schema},
|
||||
"reglas_estrictas_nomenclatura": _REGLAS_NOMENCLATURA,
|
||||
|
||||
@@ -347,6 +347,8 @@ class InvoiceProcessingService:
|
||||
alias_map: Optional[Dict[str, str]] = None,
|
||||
tiff_page_range: Optional[Tuple[int, int]] = None,
|
||||
structure_mode: str = "radicados",
|
||||
retry_on_empty: bool = False,
|
||||
radicado_filter: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Procesa radicados con selección de campos y CSV incremental. (Delegado a BatchProcessor)"""
|
||||
return self.batch_processor.process_root(
|
||||
@@ -356,7 +358,9 @@ class InvoiceProcessingService:
|
||||
instruction_override=instruction_override, on_usage=on_usage,
|
||||
on_error_retry=on_error_retry,
|
||||
alias_map=alias_map, tiff_page_range=tiff_page_range,
|
||||
structure_mode=structure_mode
|
||||
structure_mode=structure_mode,
|
||||
retry_on_empty=retry_on_empty,
|
||||
radicado_filter=radicado_filter,
|
||||
)
|
||||
|
||||
def estimate_tokens_and_cost(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Build script para IPSExtractor
|
||||
REM Genera el ejecutable con PyInstaller y opcionalmente el instalador con
|
||||
REM Inno Setup.
|
||||
REM
|
||||
REM Uso:
|
||||
REM build_installer.bat (solo PyInstaller)
|
||||
REM build_installer.bat --inno (PyInstaller + Inno Setup)
|
||||
REM ============================================================================
|
||||
|
||||
setlocal
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
cd /d "%ROOT%"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo IPSExtractor - Build
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM -- Verificar Python ---
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python no encontrado en PATH.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM -- Verificar PyInstaller ---
|
||||
python -c "import PyInstaller" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Instalando PyInstaller...
|
||||
pip install pyinstaller
|
||||
)
|
||||
|
||||
REM -- Limpiar builds anteriores ---
|
||||
echo Limpiando builds anteriores...
|
||||
if exist "build" rmdir /s /q "build"
|
||||
if exist "dist\IPSExtractor" rmdir /s /q "dist\IPSExtractor"
|
||||
|
||||
REM -- Ejecutar PyInstaller ---
|
||||
echo.
|
||||
echo Ejecutando PyInstaller...
|
||||
echo.
|
||||
pyinstaller ipsextractor.spec --noconfirm
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: PyInstaller fallo. Revise los errores arriba.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Build exitoso!
|
||||
echo Output: dist\IPSExtractor\
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM -- Inno Setup (opcional) ---
|
||||
if /i "%~1"=="--inno" (
|
||||
echo Buscando Inno Setup...
|
||||
|
||||
set "ISCC="
|
||||
if exist "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" (
|
||||
set "ISCC=C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
if exist "C:\Program Files\Inno Setup 6\ISCC.exe" (
|
||||
set "ISCC=C:\Program Files\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
|
||||
if not defined ISCC (
|
||||
echo AVISO: Inno Setup no encontrado. Instale Inno Setup 6 para generar el instalador.
|
||||
echo Descargue en: https://jrsoftware.org/isdl.php
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo Generando instalador con Inno Setup...
|
||||
"%ISCC%" "%ROOT%installer.iss"
|
||||
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Inno Setup fallo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Instalador generado en: dist\
|
||||
echo ========================================
|
||||
)
|
||||
|
||||
pause
|
||||
+125
-6
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
; ============================================================================
|
||||
; Inno Setup Script para IPSExtractor
|
||||
;
|
||||
; Prerequisito: ejecutar build_installer.bat primero para generar dist\IPSExtractor
|
||||
; Luego: build_installer.bat --inno o compilar este .iss directamente
|
||||
;
|
||||
; Descargar Inno Setup: https://jrsoftware.org/isdl.php
|
||||
; ============================================================================
|
||||
|
||||
#define MyAppName "IPSExtractor"
|
||||
#define MyAppVersion "1.0.0"
|
||||
#define MyAppPublisher "IPSExtractor"
|
||||
#define MyAppExeName "IPSExtractor.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=dist
|
||||
OutputBaseFilename=IPSExtractor_Setup_{#MyAppVersion}
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
SetupLogging=yes
|
||||
|
||||
[Languages]
|
||||
Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Crear acceso directo en el Escritorio"; GroupDescription: "Accesos directos:"
|
||||
|
||||
[Files]
|
||||
; Todos los archivos de dist\IPSExtractor
|
||||
Source: "dist\IPSExtractor\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{group}\Desinstalar {#MyAppName}"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "Ejecutar {#MyAppName}"; Flags: nowait postinstall skipifsilent
|
||||
Reference in New Issue
Block a user