refactor: Introduce accumulate_usage function for token aggregation, enhance alias map expansion in BatchProcessor, and streamline error handling in PreclassificationService. Update PDF processing utilities for improved image conversion and error logging.

This commit is contained in:
erik
2026-03-01 20:55:52 -05:00
parent 3c258ebf12
commit e7fde25227
29 changed files with 321 additions and 1206 deletions
+20 -38
View File
@@ -14,8 +14,8 @@ if TYPE_CHECKING:
from application.profiles import get_profile
from application.prompts import PromptBuilder
from application.normalizer import normalize_to_schema
from application.costs import compute_cost_from_usage
from application.file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados
from application.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 core.utils import collect_radicado_docs, find_first_matching_file, collect_documents_flat
@@ -433,24 +433,26 @@ class BatchProcessor:
# --- Helpers ---
def _expand_alias_map(self, alias_map: dict) -> dict:
"""Expande alias con wildcard `/*/' al sub-campo equivalente."""
expanded = dict(alias_map)
for orig_key, alias_value in list(alias_map.items()):
if "/*/" in orig_key:
sub = orig_key.split("/*/")[-1]
if sub and sub not in expanded:
expanded[sub] = alias_value
return expanded
def _prepare_csv_header(self, fields, alias_map, override):
if override:
if override:
return override
header_base = ['radicado'] + list(fields or [])
if not alias_map:
return header_base
alias_map2 = dict(alias_map)
# Expandir alias items
for orig_key, alias_value in list(alias_map.items()):
if "/*/" in orig_key:
sub = orig_key.split("/*/")[-1]
if sub and sub not in alias_map2:
alias_map2[sub] = alias_value
alias_map2 = self._expand_alias_map(alias_map)
raw_header = [alias_map2.get(h, h) for h in header_base]
# Deduplicar conservando orden
seen = set()
seen: set = set()
final_header = []
for h in raw_header:
if h not in seen:
@@ -466,11 +468,7 @@ class BatchProcessor:
pass
def _accumulate_usage(self, agg, new_usage):
if not isinstance(new_usage, dict): return
agg["prompt_tokens"] += int(new_usage.get("prompt_tokens") or 0)
agg["completion_tokens"] += int(new_usage.get("completion_tokens") or 0)
details = new_usage.get("prompt_tokens_details") or {}
agg["prompt_tokens_details"]["cached_tokens"] += int(details.get("cached_tokens") or 0)
accumulate_usage(agg, new_usage)
def _report_usage(self, callback, rad, phase, usage):
if callback and usage:
@@ -521,13 +519,9 @@ class BatchProcessor:
logger.exception(f"Error escribiendo CSV para radicado {rad}", extra={"radicado": rad})
def _apply_alias_to_rows(self, rows, alias_map):
if not alias_map: return rows
alias_map2 = dict(alias_map)
for orig_key, alias_value in list(alias_map.items()):
if "/*/" in orig_key:
sub = orig_key.split("/*/")[-1]
if sub and sub not in alias_map2:
alias_map2[sub] = alias_value
if not alias_map:
return rows
alias_map2 = self._expand_alias_map(alias_map)
aliased_rows = []
for r in rows:
@@ -562,18 +556,6 @@ class BatchProcessor:
aliased_rows.append(r)
return aliased_rows
def _handle_error(self, resultados, rad, e, csv_path, header, pdf_path=None):
error_obj = {
"radicado": rad,
"error": str(e)
}
if pdf_path: error_obj["_source_pdf"] = pdf_path
resultados.append(error_obj)
logger.exception(f"Error procesando radicado {rad}", extra={"radicado": rad})
if csv_path:
header_err = header if header else ["radicado", "error"]
self.csv_manager.append_rows(csv_path, [error_obj], header_err)
def _handle_error_async(self, rad, e, csv_path, header, pdf_path=None):
error_obj = {
"radicado": rad,
+10
View File
@@ -81,6 +81,16 @@ def compute_cost_from_usage(
}
def accumulate_usage(agg: Dict[str, Any], new_usage: Dict[str, Any] | None) -> None:
"""Acumula tokens de `new_usage` en el dict agregado `agg` (in-place)."""
if not isinstance(new_usage, dict):
return
agg["prompt_tokens"] += int(new_usage.get("prompt_tokens") or 0)
agg["completion_tokens"] += int(new_usage.get("completion_tokens") or 0)
details = new_usage.get("prompt_tokens_details") or {}
agg["prompt_tokens_details"]["cached_tokens"] += int(details.get("cached_tokens") or 0)
def sum_costs(a: Dict[str, Any] | None, b: Dict[str, Any] | None) -> Dict[str, Any]:
"""Suma dos objetos de costos."""
def safe(x): return float(x) if x is not None else 0.0
+8
View File
@@ -153,6 +153,14 @@ class DatabaseManager:
conn.close()
return [dict(row) for row in rows]
def update_result_status(self, result_id: int, status: str) -> None:
"""Actualiza el status de un resultado existente."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("UPDATE results SET status = ? WHERE id = ?", (status, result_id))
conn.commit()
conn.close()
def get_matches_for_result(self, result_id: int) -> List[Dict[str, Any]]:
"""Obtiene el detalle de matches para un resultado específico."""
conn = sqlite3.connect(self.db_path)
+2 -6
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
from application.profiles import get_profile
from application.prompts import PromptBuilder
from application.normalizer import normalize_to_schema
from application.costs import compute_cost_from_usage, sum_costs
from application.costs import compute_cost_from_usage, sum_costs, accumulate_usage
from application.file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados
from core.utils import collect_radicado_docs, collect_documents_flat
@@ -107,11 +107,7 @@ class Estimator:
return self._build_result(usage_ext, usage_norm, len(entries), first["radicado"])
def _accumulate_usage(self, agg, new_usage):
if not isinstance(new_usage, dict): return
agg["prompt_tokens"] += int(new_usage.get("prompt_tokens") or 0)
agg["completion_tokens"] += int(new_usage.get("completion_tokens") or 0)
details = new_usage.get("prompt_tokens_details") or {}
agg["prompt_tokens_details"]["cached_tokens"] += int(details.get("cached_tokens") or 0)
accumulate_usage(agg, new_usage)
def _build_result(self, usage_ext, usage_norm, total_docs, first_radicado):
model_ext = getattr(self.service.extractor, "model", None)
+1 -3
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
import logging
import time
import os
from typing import Any, Dict, List, Optional
from .config import settings
@@ -76,7 +75,6 @@ def normalize_to_schema(
data: Dict[str, Any],
paths: List[str],
profile_name: str,
model: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Usa un LLM para mapear `data` a un objeto que siga el template canónico.
@@ -168,7 +166,7 @@ def normalize_to_schema(
"normalized": False,
"error": str(ex),
"duration_ms": dt_ms,
"model": (eff_model if 'eff_model' in locals() else os.environ.get("NORMALIZE_MODEL", model or settings.api.model or "gpt-4.1-nano")),
"model": (eff_model if 'eff_model' in locals() else settings.api.model or "gpt-4.1-nano"),
"base_url": (eff_base_url if 'eff_base_url' in locals() else (settings.api.base_url or "https://api.openai.com/v1")),
"radicado": (data.get("radicado") if isinstance(data, dict) else None),
},
+20 -74
View File
@@ -1,20 +1,20 @@
import fnmatch
import json
import logging
import os
from typing import List, Dict, Any, Optional, Tuple, Callable
import re
import sqlite3
import shutil
import time
from typing import List, Dict, Any, Optional, Tuple, Callable
import pandas as pd
from application.strategies import OllamaExtractor
from application.database import DatabaseManager
from core.pdf_utils import document_to_images_bs64
from core.utils import collect_radicado_docs
logger = logging.getLogger(__name__)
import shutil
import pandas as pd
class PreclassificationService:
def __init__(self, extractor: OllamaExtractor):
self.extractor = extractor
@@ -77,21 +77,11 @@ class PreclassificationService:
Organiza los archivos MATCH en carpetas jerárquicas.
Devuelve (total_copiados, total_errores).
"""
# Obtener modo del Run
# Necesitamos leer config del Run
conn = sqlite3.connect(self.db.db_path)
c = conn.cursor()
c.execute("SELECT config FROM runs WHERE id = ?", (run_id,))
row = c.fetchone()
conn.close()
run = self.db.get_run(run_id)
mode = "radicados"
if row and row[0]:
try:
cfg = json.loads(row[0])
mode = str(cfg.get("mode", "radicados") or "radicados").strip().lower()
except Exception:
mode = "radicados"
if run:
cfg = run.get("config") or {}
mode = str(cfg.get("mode", "radicados") or "radicados").strip().lower()
if mode not in ("radicados", "general"):
mode = "radicados"
@@ -232,11 +222,7 @@ class PreclassificationService:
])
total = len(radicados)
if mode == "general":
# Si es general, quizás queramos reportar progreso por archivo, no por carpeta "."
# Pero _identify_files nos da los archivos.
pass
# Preparar prompt
keys_to_extract = [p["key"] for p in patterns]
prompt = self._build_prompt(keys_to_extract)
@@ -281,9 +267,7 @@ class PreclassificationService:
else:
current_radicado = base_name
# --- CORRECCIÓN FORZADA: Si sigue siendo un punto o vacío, usar timestamp o hash ---
if str(current_radicado).strip() in (".", ""):
import time
current_radicado = f"FILE_{int(time.time()*1000)}_{f_idx}"
logger.info(f"Procesando archivo [{f_idx}/{total_files}]: {file_path} -> Radicado: {current_radicado}")
@@ -296,22 +280,7 @@ class PreclassificationService:
pass # Evitar romper el loop si la GUI falla
try:
# 4. Convertir a imágenes
# Manejo de rangos
tiff_range = None
if page_range[0]: # enabled
# document_to_images_bs64 espera (start, end)
# Nota: pdf_pages_to_images_bs64 usa start, end.
# document_to_images_bs64 usa tiff_page_range=(start, end).
# Para PDF hay que ver como lo manejamos si usamos document_to_images_bs64
# La función document_to_images_bs64 no expone rango para PDF directamente,
# pero pdf_to_images_bs64 sí via pdf_pages_to_images_bs64?
# No, pdf_to_images_bs64 convierte todo.
# Vamos a usar document_to_images_bs64 y si es PDF y hay rango, lo cortamos post-procesamiento o usamos función específica.
# Mejor: document_to_images_bs64 llama a pdf_to_images_bs64 que NO filtra.
# Debemos implementar logica de rango aqui.
pass
# 4. Convertir a imágenes (con soporte de rango si está habilitado)
images_b64 = self._get_images(file_path, page_range)
if not images_b64:
@@ -324,12 +293,7 @@ class PreclassificationService:
# 6. Parseo y Validación
parsed = self._parse_json_response(raw_text)
# Evaluar reglas
is_match = True
# Si no hay patrones definidos, quizás asumimos match si responde JSON válido?
# Pero el usuario pidió validar patrones.
# Guardar resultado cabecera
result_id = self.db.add_result(run_id, current_radicado, file_path, "PROCESSED", raw_text)
@@ -403,10 +367,7 @@ class PreclassificationService:
if not target_info.get("enabled"):
return all_files
# Filtrar por patron
pattern = target_info.get("pattern", "*").lower()
# Convertir glob simple a regex o usar fnmatch
import fnmatch
filtered = [f for f in all_files if fnmatch.fnmatch(os.path.basename(f).lower(), pattern)]
return filtered
@@ -414,25 +375,17 @@ class PreclassificationService:
"""Convierte archivo a imagenes, aplicando rango si es necesario."""
enabled, start, end = page_range
# Si rango habilitado, usar lógica especifica
if enabled:
from core.pdf_utils import pdf_pages_to_images_bs64
ext = os.path.splitext(file_path)[1].lower()
s, e = max(1, start), max(1, end)
if s > e:
s, e = e, s
if ext == ".pdf":
from core.pdf_utils import pdf_pages_to_images_bs64
# pdf_pages_to_images_bs64 espera start, end (inclusive)
# Validar indices positivos
s = max(1, start)
e = max(1, end)
if s > e: s, e = e, s
return pdf_pages_to_images_bs64(file_path, s, e, dpi=300) # DPI medio para velocidad?
return pdf_pages_to_images_bs64(file_path, s, e, dpi=300)
elif ext in (".tif", ".tiff"):
# document_to_images_bs64 soporta tiff_page_range
s = max(1, start)
e = max(1, end)
if s > e: s, e = e, s
return document_to_images_bs64(file_path, tiff_page_range=(s, e), dpi=300)
# Sin rango
return document_to_images_bs64(file_path, dpi=300)
def _build_prompt(self, keys: List[str]) -> str:
@@ -460,11 +413,4 @@ Campos a buscar: {keys_str}"""
return {}
def _update_result_status(self, result_id: int, status: str):
# Actualización hacky directa porque DatabaseManager no tiene update expuesto
# Podría agregar update_result en DatabaseManager, pero por brevedad:
import sqlite3
conn = sqlite3.connect(self.db.db_path)
c = conn.cursor()
c.execute("UPDATE results SET status = ? WHERE id = ?", (status, result_id))
conn.commit()
conn.close()
self.db.update_result_status(result_id, status)
+50 -60
View File
@@ -13,40 +13,54 @@ from typing import Dict, List, Any
from .profiles import get_profile
_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",
]
_REGLAS_NOMENCLATURA = [
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
"No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)",
"Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}",
"No cambies nombres de claves por 'equivalentes'",
"Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado",
]
_NOTA_LISTAS = "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}"
class PromptBuilder:
@staticmethod
def build(selected_fields: List[str], schema: Dict[str, str], profile_name: str | None = None) -> str:
def _base_payload(
profile_name: str,
fields: List[str],
schema: Dict[str, str],
extra_meta: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
profile = get_profile(profile_name or "invoice")
field_schema = {k: schema[k] for k in selected_fields if k in schema}
json_prompt: Dict[str, Any] = {
"meta": {
"perfil": profile.get("name", "invoice"),
"descripcion": "Extracción estructurada desde OCR de facturas (sin datos personales reales)",
},
"instrucciones": [
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
"No inventes claves nuevas ni cambies nombres existentes",
"Si un campo no existe, usa null o [] según corresponda",
"Ten especial cuidado en no confundir el número de autorización con el código"
],
"campos_requeridos": selected_fields,
"schema_referencia": field_schema,
"reglas_estrictas_nomenclatura": [
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
"No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)",
"Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}",
"No cambies nombres de claves por 'equivalentes'",
"Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado",
],
"salida": {
"tipo": "json_object",
"raiz": None,
},
"nota_listas": "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}",
meta: Dict[str, Any] = {
"perfil": profile.get("name", "invoice"),
"descripcion": "Extracción estructurada desde OCR de facturas (sin datos personales reales)",
}
if extra_meta:
meta.update(extra_meta)
return {
"meta": meta,
"instrucciones": _INSTRUCCIONES,
"campos_requeridos": fields,
"schema_referencia": {k: schema[k] for k in fields if k in schema},
"reglas_estrictas_nomenclatura": _REGLAS_NOMENCLATURA,
"salida": {"tipo": "json_object", "raiz": None},
"nota_listas": _NOTA_LISTAS,
}
return json.dumps(json_prompt, indent=2, ensure_ascii=False)
@staticmethod
def build(selected_fields: List[str], schema: Dict[str, str], profile_name: str | None = None) -> str:
payload = PromptBuilder._base_payload(profile_name or "invoice", selected_fields, schema)
return json.dumps(payload, indent=2, ensure_ascii=False)
@staticmethod
def build_normalization_messages(profile_name: str, template: Dict[str, Any], source_json: Dict[str, Any]) -> List[Dict[str, str]]:
@@ -81,35 +95,11 @@ class PromptBuilder:
doc_type_name se usa en meta para trazabilidad; no afecta el esquema.
"""
profile = get_profile(profile_name or "invoice")
field_schema = {k: schema_for_type.get(k) for k in selected_paths_for_type if k in schema_for_type}
json_prompt: Dict[str, Any] = {
"meta": {
"perfil": profile.get("name", "invoice"),
"tipo_documento": doc_type_name,
"descripcion": "Extracción estructurada desde OCR (sin datos personales reales)",
},
"instrucciones": [
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
"No inventes claves nuevas ni cambies nombres existentes",
"Si un campo no existe, usa null o [] según corresponda",
"Ten especial cuidado en no confundir el número de autorización con el código"
],
"campos_requeridos": selected_paths_for_type,
"schema_referencia": field_schema,
"reglas_estrictas_nomenclatura": [
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
"No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)",
"Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}",
"No cambies nombres de claves por 'equivalentes'",
"Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado",
],
"salida": {
"tipo": "json_object",
"raiz": None,
},
"nota_listas": "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}",
}
return json.dumps(json_prompt, indent=2, ensure_ascii=False)
payload = PromptBuilder._base_payload(
profile_name or "invoice",
selected_paths_for_type,
schema_for_type,
extra_meta={"tipo_documento": doc_type_name},
)
return json.dumps(payload, indent=2, ensure_ascii=False)
+78 -146
View File
@@ -12,9 +12,6 @@ import logging
import os
import time
import asyncio
from typing import List, Dict, Any, Optional, Tuple
from typing import Callable
from .strategies import InvoiceExtractorStrategy
from .prompts import PromptBuilder
from .profiles import get_profile
@@ -51,6 +48,50 @@ class InvoiceProcessingService:
except Exception:
self._csv_lock = None
def _convert_to_images(self, pdf_path: str, dpi: int, thread_count: int, tiff_pages, tiff_page_range) -> List[str]:
"""Convierte el documento a imágenes base64."""
filename = os.path.basename(pdf_path)
tiff_pages_cfg = getattr(settings.imaging, 'tiff_pages', (2, 3)) if tiff_pages is None else tiff_pages
poppler = getattr(settings.imaging, 'poppler_path', None)
try:
return document_to_images_bs64(
pdf_path, dpi=dpi, thread_count=thread_count,
tiff_pages=tiff_pages_cfg, tiff_page_range=tiff_page_range,
poppler_path=poppler,
)
except Exception as e:
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
raise
def _apply_extractor_fallback(self, pdf_path: str) -> List[str]:
"""Carga imágenes con DPI reducido (máx 5 páginas) para el fallback."""
fb_dpi = 400
tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3))
poppler = getattr(settings.imaging, 'poppler_path', None)
images = document_to_images_bs64(pdf_path, dpi=fb_dpi, tiff_pages=tiff_pages_fb, poppler_path=poppler)
return images[:5] if len(images) > 5 else images
def _parse_model_output(self, model_output: dict, pdf_path: str, dpi_cfg: int) -> Tuple[Any, Any]:
"""Extrae y parsea el JSON del output del modelo."""
filename = os.path.basename(pdf_path)
raw_text = model_output.get("raw", "")
usage = model_output.get("usage")
json_text = extract_json_from_text(raw_text)
if not json_text:
logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path})
raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.")
data = json.loads(json_text)
try:
if isinstance(data, dict):
top_keys = list(data.keys())
preview = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
else:
logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
except Exception:
pass
return data, usage
@log_execution_time(logger=logger)
def extract_full_from_pdf(
self,
@@ -59,90 +100,34 @@ class InvoiceProcessingService:
*,
tiff_page_range: Optional[Tuple[int, int]] = None,
history: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
) -> Tuple[Any, Any]:
"""Extrae un JSON completo desde un documento (PDF/TIFF) usando el extractor.
Estrategia: intenta con configuración por defecto. Si el backend responde
con un error (p. ej., 500 por payload demasiado grande), reintenta con
menor DPI y recortando a las primeras 5 páginas.
En caso de error, reintenta con DPI reducido y máximo 5 páginas.
"""
filename = os.path.basename(pdf_path)
logger.info(f"Iniciando extracción de documento: {filename}", extra={"pdf": pdf_path})
t0 = time.time()
# Preparar imágenes según configuración
dpi_cfg = settings.imaging.pdf_dpi
thr_cfg = settings.imaging.pdf_thread_count
images_t0 = time.time()
tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3))
try:
images_b64 = document_to_images_bs64(
pdf_path,
dpi=dpi_cfg,
thread_count=thr_cfg,
tiff_pages=tiff_pages,
tiff_page_range=tiff_page_range,
)
except Exception as e:
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
raise
images_ms = int((time.time() - images_t0) * 1000)
logger.debug(f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg}, ms={images_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
# Selección de instrucción por defecto según el contexto
profile = get_profile('invoice')
text_instruction = instruction if instruction is not None else profile["default_instruction"]
# Métricas del prompt (sin registrar el contenido completo por privacidad)
inst_preview = (text_instruction[:180] + "…") if isinstance(text_instruction, str) and len(text_instruction) > 180 else text_instruction
inst_len = len(text_instruction) if isinstance(text_instruction, str) else 0
hist_len = len(history) if history else 0
logger.debug(f"Enviando a extractor {filename} (images={len(images_b64)}, prompt_len={inst_len}, history_len={hist_len})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
try:
call_t0 = time.time()
model_output = self.extractor.extract(images_b64, text_instruction, history=history)
call_ms = int((time.time() - call_t0) * 1000)
logger.debug(f"Extractor OK para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
except Exception as e:
logger.warning(f"Fallo en extracción por defecto para {filename}. Reintentando con DPI reducido.", extra={"pdf": pdf_path, "error": str(e)})
# Fallback: DPI reducido y limitar páginas
fb_dpi = 400
tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3))
# En fallback NO aplicamos `tiff_page_range`; se usa rango propio (pages<=5).
images_b64_fb = document_to_images_bs64(pdf_path, dpi=fb_dpi, tiff_pages=tiff_pages_fb)
# Limitar a 5 imágenes en fallback para controlar tamaño
if len(images_b64_fb) > 5:
images_b64_fb = images_b64_fb[:5]
logger.debug(f"Fallback activo para {filename} (images={len(images_b64_fb)}, dpi={fb_dpi})", extra={"pdf": pdf_path, "dpi": fb_dpi})
call_t0 = time.time()
model_output = self.extractor.extract(images_b64_fb, text_instruction, history=history)
call_ms = int((time.time() - call_t0) * 1000)
logger.debug(f"Extractor OK tras fallback para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": fb_dpi})
raw_text = model_output.get("raw", "")
usage = model_output.get("usage")
json_text = extract_json_from_text(raw_text)
if not json_text:
logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path})
raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.")
data = json.loads(json_text)
# Resumen de claves extraídas (sin volcar todo el JSON)
images_b64 = self._convert_to_images(pdf_path, dpi_cfg, thr_cfg, tiff_pages, tiff_page_range)
logger.debug(
f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg})",
extra={"pdf": pdf_path, "dpi": dpi_cfg},
)
try:
if isinstance(data, dict):
top_keys = list(data.keys())
preview_keys = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview_keys})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
else:
logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
except Exception:
pass
total_ms = int((time.time() - t0) * 1000)
logger.info(f"Extracción finalizada exitosamente: {filename} (total_ms={total_ms})", extra={"pdf": pdf_path})
model_output = self.extractor.extract(images_b64, text_instruction, history=history)
except Exception as e:
logger.warning(f"Fallo en extracción. Reintentando con DPI reducido: {filename}", extra={"pdf": pdf_path, "error": str(e)})
images_b64_fb = self._apply_extractor_fallback(pdf_path)
model_output = self.extractor.extract(images_b64_fb, text_instruction, history=history)
data, usage = self._parse_model_output(model_output, pdf_path, dpi_cfg)
logger.info(f"Extracción finalizada: {filename} (total_ms={int((time.time()-t0)*1000)})", extra={"pdf": pdf_path})
return data, usage
@log_execution_time(logger=logger)
@@ -153,91 +138,38 @@ class InvoiceProcessingService:
*,
tiff_page_range: Optional[Tuple[int, int]] = None,
history: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
) -> Tuple[Any, Any]:
"""Versión asíncrona de extract_full_from_pdf."""
filename = os.path.basename(pdf_path)
logger.info(f"Iniciando extracción asíncrona de documento: {filename}", extra={"pdf": pdf_path})
t0 = time.time()
# Preparar imágenes según configuración
dpi_cfg = settings.imaging.pdf_dpi
thr_cfg = settings.imaging.pdf_thread_count
images_t0 = time.time()
tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3))
try:
# Ejecutar conversión de imágenes en thread pool
images_b64 = await asyncio.to_thread(
document_to_images_bs64,
pdf_path,
dpi=dpi_cfg,
thread_count=thr_cfg,
tiff_pages=tiff_pages,
tiff_page_range=tiff_page_range,
)
except Exception as e:
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
raise
images_ms = int((time.time() - images_t0) * 1000)
logger.debug(f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg}, ms={images_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
# Selección de instrucción por defecto según el contexto
profile = get_profile('invoice')
text_instruction = instruction if instruction is not None else profile["default_instruction"]
inst_len = len(text_instruction) if isinstance(text_instruction, str) else 0
hist_len = len(history) if history else 0
logger.debug(f"Enviando a extractor {filename} (images={len(images_b64)}, prompt_len={inst_len}, history_len={hist_len})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
try:
call_t0 = time.time()
model_output = await self.extractor.extract_async(images_b64, text_instruction, history=history)
call_ms = int((time.time() - call_t0) * 1000)
logger.debug(f"Extractor OK para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
except Exception as e:
logger.warning(f"Fallo en extracción por defecto para {filename}. Reintentando con DPI reducido.", extra={"pdf": pdf_path, "error": str(e)})
# Fallback: DPI reducido y limitar páginas
fb_dpi = 400
tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3))
images_b64_fb = await asyncio.to_thread(
document_to_images_bs64,
pdf_path,
dpi=fb_dpi,
tiff_pages=tiff_pages_fb
images_b64 = await asyncio.to_thread(
self._convert_to_images, pdf_path, dpi_cfg, thr_cfg, tiff_pages, tiff_page_range,
)
except Exception as e:
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
raise
# Limitar a 5 imágenes en fallback para controlar tamaño
if len(images_b64_fb) > 5:
images_b64_fb = images_b64_fb[:5]
logger.debug(f"Fallback activo para {filename} (images={len(images_b64_fb)}, dpi={fb_dpi})", extra={"pdf": pdf_path, "dpi": fb_dpi})
call_t0 = time.time()
model_output = await self.extractor.extract_async(images_b64_fb, text_instruction, history=history)
call_ms = int((time.time() - call_t0) * 1000)
logger.debug(f"Extractor OK tras fallback para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": fb_dpi})
raw_text = model_output.get("raw", "")
usage = model_output.get("usage")
json_text = extract_json_from_text(raw_text)
if not json_text:
logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path})
raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.")
data = json.loads(json_text)
# Resumen de claves extraídas (sin volcar todo el JSON)
logger.debug(
f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg})",
extra={"pdf": pdf_path, "dpi": dpi_cfg},
)
try:
if isinstance(data, dict):
top_keys = list(data.keys())
preview_keys = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview_keys})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
else:
logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
except Exception:
pass
total_ms = int((time.time() - t0) * 1000)
logger.info(f"Extracción finalizada exitosamente: {filename} (total_ms={total_ms})", extra={"pdf": pdf_path})
model_output = await self.extractor.extract_async(images_b64, text_instruction, history=history)
except Exception as e:
logger.warning(f"Fallo en extracción. Reintentando con DPI reducido: {filename}", extra={"pdf": pdf_path, "error": str(e)})
images_b64_fb = await asyncio.to_thread(self._apply_extractor_fallback, pdf_path)
model_output = await self.extractor.extract_async(images_b64_fb, text_instruction, history=history)
data, usage = self._parse_model_output(model_output, pdf_path, dpi_cfg)
logger.info(f"Extracción finalizada: {filename} (total_ms={int((time.time()-t0)*1000)})", extra={"pdf": pdf_path})
return data, usage
def build_schema_from_first(
+57 -144
View File
@@ -27,177 +27,90 @@ class InvoiceExtractorStrategy(ABC):
raise NotImplementedError
_LOG_EXTRA_BASE = {"dpi": '-', "pdf": '-', "radicado": '-'}
class OllamaExtractor(InvoiceExtractorStrategy):
"""Extractor contra un servidor Ollama/OpenAI-compatible.
Parámetros configurables: `api_key`, `base_url` y `model`.
"""
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, model: Optional[str] = None, llm_client: Optional[LLMClient] = None) -> None:
# Defaults desde settings si no se proveen
self.api_key = api_key or settings.api.api_key
self.base_url = base_url or settings.api.base_url
self.model = model or settings.api.model
self.llm = llm_client or LLMClient(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=settings.api.request_timeout_s)
self._logger = logging.getLogger(__name__)
def _build_messages(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
content: List[Dict[str, Any]] = [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img}"}}
for img in images_b64
]
content.append({"type": "text", "text": instruction})
messages = list(history) if history else []
messages.append({"role": "user", "content": content})
return messages
def _log_call_start(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> None:
prompt_len = len(instruction) if isinstance(instruction, str) else 0
self._logger.info(
"backend_call_start",
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
"timeout_s": settings.api.request_timeout_s, "images": len(images_b64),
"prompt_len": prompt_len, "history_len": len(history) if history else 0},
)
self._logger.debug(
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
extra=_LOG_EXTRA_BASE,
)
def _log_call_ok(self, elapsed: float) -> None:
self._logger.info(
"Extracción completada",
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
"timeout_s": settings.api.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
)
self._logger.debug(f"Backend OK (elapsed_ms={int(elapsed*1000)})", extra=_LOG_EXTRA_BASE)
def _log_call_error(self, elapsed: float) -> None:
self._logger.exception(
"Error en llamada al backend",
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
"timeout_s": settings.api.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
)
self._logger.debug(f"Backend error tras {int(elapsed*1000)} ms", extra=_LOG_EXTRA_BASE)
def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Construye el prompt multimodal y devuelve el texto crudo del modelo."""
content: List[Dict[str, Any]] = []
for image_b64 in images_b64:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
})
content.append({"type": "text", "text": instruction})
messages = []
if history:
messages.extend(history)
messages.append({"role": "user", "content": content})
messages = self._build_messages(images_b64, instruction, history)
self._log_call_start(images_b64, instruction, history)
start = time.time()
try:
# Trazas DEBUG del request (sin exponer imágenes ni texto completo)
prompt_len = len(instruction) if isinstance(instruction, str) else 0
# Log INFO con metadatos efectivos de la llamada
self._logger.info(
"backend_call_start",
extra={
"base_url": self.base_url,
"model": self.model,
"timeout_s": settings.api.request_timeout_s,
"images": len(images_b64),
"prompt_len": prompt_len,
"history_len": len(history) if history else 0,
"dpi": '-', "pdf": '-', "radicado": '-',
},
)
self._logger.debug(
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
)
text, usage_dict = self.llm.chat_text(
messages=messages,
model=self.model,
timeout_s=settings.api.request_timeout_s,
component="extractor",
messages=messages, model=self.model,
timeout_s=settings.api.request_timeout_s, component="extractor",
)
elapsed = time.time() - start
self._logger.info(
"Extracción completada",
extra={
"dpi": '-', "pdf": '-', "radicado": '-',
"base_url": self.base_url, "model": self.model,
"timeout_s": settings.api.request_timeout_s,
"elapsed_ms": int(elapsed * 1000),
},
)
self._logger.debug(
f"Backend OK (elapsed_ms={int(elapsed*1000)})",
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
)
return {
"raw": text,
"usage": usage_dict,
}
self._log_call_ok(time.time() - start)
return {"raw": text, "usage": usage_dict}
except Exception:
elapsed = time.time() - start
self._logger.exception(
"Error en llamada al backend",
extra={
"dpi": '-', "pdf": '-', "radicado": '-',
"base_url": self.base_url, "model": self.model,
"timeout_s": settings.api.request_timeout_s,
"elapsed_ms": int(elapsed * 1000),
},
)
try:
self._logger.debug(
f"Backend error tras {int(elapsed*1000)} ms",
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
)
except Exception:
pass
self._log_call_error(time.time() - start)
raise
async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Construye el prompt multimodal y devuelve el texto crudo del modelo de forma asíncrona."""
content: List[Dict[str, Any]] = []
for image_b64 in images_b64:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
})
content.append({"type": "text", "text": instruction})
messages = []
if history:
messages.extend(history)
messages.append({"role": "user", "content": content})
"""Versión asíncrona de extract."""
messages = self._build_messages(images_b64, instruction, history)
self._log_call_start(images_b64, instruction, history)
start = time.time()
try:
# Trazas DEBUG del request (sin exponer imágenes ni texto completo)
prompt_len = len(instruction) if isinstance(instruction, str) else 0
# Log INFO con metadatos efectivos de la llamada
self._logger.info(
"backend_call_start",
extra={
"base_url": self.base_url,
"model": self.model,
"timeout_s": settings.api.request_timeout_s,
"images": len(images_b64),
"prompt_len": prompt_len,
"history_len": len(history) if history else 0,
"dpi": '-', "pdf": '-', "radicado": '-',
},
)
self._logger.debug(
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
)
text, usage_dict = await self.llm.chat_text_async(
messages=messages,
model=self.model,
timeout_s=settings.api.request_timeout_s,
component="extractor",
messages=messages, model=self.model,
timeout_s=settings.api.request_timeout_s, component="extractor",
)
elapsed = time.time() - start
self._logger.info(
"Extracción completada",
extra={
"dpi": '-', "pdf": '-', "radicado": '-',
"base_url": self.base_url, "model": self.model,
"timeout_s": settings.api.request_timeout_s,
"elapsed_ms": int(elapsed * 1000),
},
)
self._logger.debug(
f"Backend OK (elapsed_ms={int(elapsed*1000)})",
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
)
return {
"raw": text,
"usage": usage_dict,
}
self._log_call_ok(time.time() - start)
return {"raw": text, "usage": usage_dict}
except Exception:
elapsed = time.time() - start
self._logger.exception(
"Error en llamada al backend",
extra={
"dpi": '-', "pdf": '-', "radicado": '-',
"base_url": self.base_url, "model": self.model,
"timeout_s": settings.api.request_timeout_s,
"elapsed_ms": int(elapsed * 1000),
},
)
try:
self._logger.debug(
f"Backend error tras {int(elapsed*1000)} ms",
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
)
except Exception:
pass
self._log_call_error(time.time() - start)
raise
def update_config(self, *, api_key: str | None = None, base_url: str | None = None, model: str | None = None) -> None:
+23 -19
View File
@@ -15,19 +15,28 @@ from PIL import Image, ImageSequence
Image.MAX_IMAGE_PIXELS = None
from application.config import settings
logger = logging.getLogger(__name__)
_DEFAULT_POPPLER = 'dependencies/poppler-24.08.0/Library/bin'
def pdf_to_images_bs64(pdf_path: str, validate: bool = True, dpi: int = 800, thread_count: int = 10) -> List[str]:
def _resolve_poppler(poppler_path: str | None) -> str:
"""Devuelve la ruta a Poppler: usa la provista o el default del proyecto."""
return poppler_path or _DEFAULT_POPPLER
def pdf_to_images_bs64(
pdf_path: str,
validate: bool = True,
dpi: int = 800,
thread_count: int = 10,
poppler_path: str | None = None,
) -> List[str]:
"""Convierte un PDF a una lista de PNGs en base64.
Usa pdf2image + Poppler. Lanza ValueError si una imagen no valida.
"""
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
poppler_bin = _resolve_poppler(poppler_path)
t0 = time.time()
logger.info("Convirtiendo PDF a imágenes", extra={"pdf": pdf_path, "dpi": dpi})
images = convert_from_path(pdf_path, dpi=dpi, thread_count=thread_count, poppler_path=poppler_bin)
@@ -50,10 +59,9 @@ def pdf_to_images_bs64(pdf_path: str, validate: bool = True, dpi: int = 800, thr
return resultados
def get_pdf_num_pages(pdf_path: str) -> int:
def get_pdf_num_pages(pdf_path: str, poppler_path: str | None = None) -> int:
"""Devuelve el número de páginas de un PDF usando pdfinfo_from_path."""
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
info = pdfinfo_from_path(pdf_path, poppler_path=poppler_bin)
info = pdfinfo_from_path(pdf_path, poppler_path=_resolve_poppler(poppler_path))
try:
return int(info.get('Pages') or info.get('pages') or 0)
except Exception:
@@ -69,12 +77,13 @@ def pdf_pages_to_images_bs64(
grayscale: bool = True,
validate: bool = False,
thread_count: int = 10,
poppler_path: str | None = None,
) -> List[str]:
"""Convierte un rango [first_page, last_page] (1-indexed, inclusivo) a PNGs base64.
Opcionalmente aplica escala de grises y omite validación para acelerar.
"""
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
poppler_bin = _resolve_poppler(poppler_path)
t0 = time.time()
logger.debug(
f"Convirtiendo rango PDF→imgs (first={first_page}, last={last_page}, dpi={dpi}, gray={grayscale})",
@@ -155,26 +164,21 @@ def document_to_images_bs64(
thread_count: int = 10,
tiff_pages: Optional[Tuple[int, int]] = (2, 3),
tiff_page_range: Optional[Tuple[int, int]] = None,
poppler_path: str | None = None,
) -> List[str]:
"""Puerta de entrada: decide conversión según la extensión (.pdf, .tif/.tiff).
`tiff_page_range` permite indicar un rango explícito (inicio, fin) 1-based
para TIFF. Si es None, se usa `tiff_pages` como valor por defecto.
`poppler_path` se pasa a las funciones PDF; si es None se usa el default del proyecto.
"""
ext = os.path.splitext(path)[1].lower()
if ext == ".pdf":
return pdf_to_images_bs64(path, validate=validate, dpi=dpi, thread_count=thread_count)
return pdf_to_images_bs64(path, validate=validate, dpi=dpi, thread_count=thread_count, poppler_path=poppler_path)
if ext in (".tif", ".tiff"):
# Prioridad:
# - (None, None) => procesar todas las páginas del TIFF (sin recorte).
# - rango explícito (inicio, fin) => usar ese rango.
# - None => usar el valor por defecto de configuración (`tiff_pages`).
effective_range = tiff_pages
if tiff_page_range is not None:
if tiff_page_range == (None, None):
effective_range = None
else:
effective_range = tiff_page_range
effective_range = None if tiff_page_range == (None, None) else tiff_page_range
return tiff_to_images_bs64(path, validate=validate, pages_range=effective_range)
raise ValueError(f"Formato no soportado: {ext}")
-270
View File
@@ -1,270 +0,0 @@
# IPSExtractor — Documentación técnica (Desarrolladores)
Este documento explica la arquitectura, módulos, flujos, modelo de datos/CSV, estados de la GUI, configuración y extensibilidad del proyecto. Contiene diagramas Mermaid incrustados.
## Índice
- [Visión general](#visión-general)
- [Arquitectura de componentes](#arquitectura-de-componentes)
- [Módulos y responsabilidades](#módulos-y-responsabilidades)
- [Flujos de ejecución](#flujos-de-ejecución)
- [Modelo de datos y CSV](#modelo-de-datos-y-csv)
- [Estados de la GUI y manejo de errores](#estados-de-la-gui-y-manejo-de-errores)
- [Configuración y ejecución](#configuración-y-ejecución)
- [Extensibilidad](#extensibilidad)
- [Registro y métricas](#registro-y-métricas)
## Visión general
La aplicación procesa carpetas con “radicados” que contienen PDFs/TIFFs de facturas. Orquesta la conversión de documentos a imágenes Base64, llama a una estrategia de extracción para obtener un JSON, normaliza el resultado a un esquema seleccionado por el usuario y exporta CSV incremental. Incluye GUI con selección interactiva de campos y estimación de costos por tokens.
## Arquitectura de componentes
```mermaid
flowchart LR
subgraph GUI
V[gui/view.py]
C[gui/controller.py]
FS[gui/field_selector.py]
end
subgraph APP[application/*]
S[service.py]
STG[strategies.py<br/>(InvoiceExtractorStrategy)]
NRM[normalizer.py]
PR[profiles.py]
PB[prompts.py]
CFG[config.py]
end
subgraph CORE
PDF[core/pdf_utils.py]
TXT[core/text_utils.py]
CSV[core/csv_utils.py]
UT[core/utils.py]
end
V --> C
C --> S
C --> FS
S --> STG
S --> NRM
S --> PR
S --> PB
S --> CFG
S --> PDF
S --> TXT
S --> CSV
S --> UT
S --> LOG[(logs/*)]
```
## Módulos y responsabilidades
- `application/service.py`: orquestación principal (extracción con fallback, schema inicial, normalización, CSV incremental, estimación de costos/tokens).
- Funciones clave:
- `extract_full_from_pdf(pdf_path, instruction?)`: conversión a imágenes, llamada a extractor, parseo JSON, fallback DPI/páginas.
- `build_schema_from_first(pdf_path, instruction?)`: genera `schema` a partir del primer documento.
- `build_schema_from_first_for_types(root_path, doc_types)`: genera `schema_by_type` usando el primer radicado y la primera coincidencia por keyword de cada tipo.
- `process_root_with_field_selection(root_path, keywords|doc_types, fields?, ...)`: flujo por radicado; soporta multi-documento; selección de campos; normalización y CSV incremental.
- `estimate_tokens_and_cost(root_path, keywords|doc_types, ...)`: simulación con primer radicado; en multi suma por tipo.
- `application/profiles.py`: perfiles por tipo de documento e instrucciones por defecto.
- `application/strategies.py`: interfaz/estrategia de extracción (`InvoiceExtractorStrategy`).
- `application/normalizer.py`: `normalize_to_schema(...)` para ajustar el JSON al esquema elegido.
- `application/prompts.py`: `PromptBuilder.build(...)` para prompts reducidos según campos seleccionados.
- `application/config.py`: `settings` (imaging, hilos, DPI, etc.).
- `core/pdf_utils.py`: conversión PDF/TIFF → imágenes Base64.
- `core/text_utils.py`: `extract_json_from_text`, `build_schema`.
- `core/csv_utils.py`: aplanado de campos no-lista, detección de la primera lista de ítems, agregaciones y encabezados.
- `core/utils.py`: utilidades (p. ej., `collect_radicado_docs`).
- `gui/controller.py`: coordina GUI ↔ servicio, selector de campos y callbacks de progreso/uso.
- `gui/field_selector.py`: diálogo de selección de paths y alias (valida duplicados y permite reproceso). En modo multi, agrupa visualmente por `Tipo` (p. ej., `Factura/...`, `Remision/...`).
- `gui/view.py`: añade lista dinámica de Tipos de Documento (Nombre Lógico + Keyword).
- `gui/view.py`: componentes visuales y actualizaciones (KPIs, barras, líneas de costo, etc.).
## Flujos de ejecución
Incluye: disparo desde GUI, descubrimiento de radicados, creación de schema, selección de campos/alias, estimación y confirmación, extracción por documento con fallback, normalización y exportación incremental a CSV.
### Secuencia general (GUI → Service → Normalización → CSV)
```mermaid
sequenceDiagram
autonumber
actor U as Usuario
participant V as gui/view.py
participant C as gui/controller.py
participant S as application/service.py
participant FS as gui/field_selector.py
participant ST as application/strategies.py
participant N as application/normalizer.py
participant CSV as core/csv_utils.py
participant TXT as core/text_utils.py
participant UT as core/utils.py
U->>V: Click "Procesar"
V->>C: on_process()
note over C: Lee root, keywords, fields opcionales
C->>S: process_root_with_field_selection(fields=None, doc_types?)
S->>UT: collect_radicado_docs()
S->>S: build_schema_from_first(first.pdf)
S->>TXT: extract_json_from_text(raw)
S-->>C: [{ _needs_fields: true, schema, first_full }]
C->>FS: Abrir diálogo de selección (schema + sample)
FS-->>C: on_submit(selected_paths, alias_map) Note: paths con prefijo de tipo `Tipo/Subruta`
C->>S: estimate_tokens_and_cost(..., doc_types?, schema_by_type?, selected_paths?)
C->>U: Confirmación (costos estimados)
U-->>C: Aceptar
C->>S: process_root_with_field_selection(fields=selected_fields, alias_map)
loop Por cada radicado
alt Multi-documento
loop Por cada Tipo
S->>ST: extract(images, prompt reducido por Tipo)
S->>N: normalize_to_schema(data, paths del Tipo)
end
S-->>S: fusiona por Tipo => fused_data = {'radicado': R, 'Factura': {...}, 'Remision': {...}}
S->>CSV: rows_from_data(fused_data, allowed_paths con prefijo)
else Single-documento
S->>ST: extract(images, prompt reducido)
S->>N: normalize_to_schema(data, paths)
S->>CSV: rows_from_data(data, allowed_paths, aggregate_list_paths?)
end
S->>S: _append_rows_to_csv(...)
S-->>C: on_usage(phase, radicado, usage, cost)
C->>V: Actualiza KPIs/barras/líneas
end
S-->>C: resultados finales (JSON)
C->>V: Imprime resultados en Output
```
### Secuencia de extracción con fallback
```mermaid
sequenceDiagram
autonumber
participant S as application/service.py
participant PDF as core/pdf_utils.py
participant ST as application/strategies.py
participant TXT as core/text_utils.py
S->>PDF: document_to_images_bs64(pdf, dpi=config, threads)
S->>ST: extract(images_b64, instruction)
alt Éxito
ST-->>S: {raw, usage}
else Error/Timeout/Payload
S->>PDF: document_to_images_bs64(pdf, dpi=fallback, pages<=5)
S->>ST: extract(images_b64_fb, instruction)
ST-->>S: {raw, usage}
end
S->>TXT: extract_json_from_text(raw)
TXT-->>S: json_text
S-->>S: json.loads(json_text) => data
```
## Modelo de datos y CSV
Describe cómo se aplanan campos no-lista, cómo se detecta la primera lista de ítems, cómo se agregan listas a columnas y cómo `allowed_paths` y los alias determinan el encabezado y las filas del CSV.
### Diagrama de datos
```mermaid
classDiagram
class ExtractedData {
+string radicado
+dict ...campos_no_lista
+Item[] items
+string _source_pdf
}
class Item {
+string Codigo
+int Cantidad
+float V/Unitario
+... otros subcampos anidados
}
ExtractedData "1" --> "0..*" Item : items
```
Notas:
- Los “campos no lista” se aplanan con separador `/`, por ejemplo: `Paciente/Nombre`, `Factura/Fecha`.
- La primera lista de objetos detectada se considera “lista de ítems principal” y se expande en columnas.
### Mapeo a CSV (`core/csv_utils.py`)
- Aplanado no-lista: `flatten_non_list_fields(data)` produce columnas para todas las hojas escalar (excluye listas).
- Detección de lista principal: `_find_first_items_list(data)` devuelve `items_key` y `items`.
- Subcolumnas de ítems: se aplanan como `items_key/*/subruta`, por ejemplo: `items/*/V/Unitario`.
- Filtrado por `allowed_paths` (selección del usuario): solo se mantienen rutas de esa lista (más `radicado` siempre).
- Agregación opcional: `aggregate_list_paths=[base_path]` genera columnas `base_path/*/subclave` con valores unidos por coma (para listas no principales).
- Encabezado final: `['radicado', ...campos_no_lista, ...campos_items]` respetando `allowed_paths` y orden alfabético de subrutas.
- Alias: el `alias_map` de la GUI renombra columnas en el CSV final (encabezado y filas).
Ejemplos de columnas resultantes:
- `radicado`
- `Factura/Número`
- `Paciente/Nombre`
- `items/*/Codigo`
- `items/*/V/Unitario`
## Estados de la GUI y manejo de errores
Estados principales: Idle → Processing → (NeedsFields) → Estimating → Confirming → Processing2 → Done/Error. La extracción tiene fallback controlado y los errores se registran y opcionalmente se reflejan como filas de error en el CSV.
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Processing: Click "Procesar"
Processing --> NeedsFields: Service devuelve _needs_fields
NeedsFields --> Estimating: on_submit(selected_fields, alias_map)
Estimating --> Confirming: Mostrar costos estimados
Confirming --> Processing2: Usuario acepta
Confirming --> Idle: Usuario cancela
Processing2 --> Done: Todos los radicados OK
Processing2 --> Error: Excepción por radicado
Done --> Idle
Error --> Idle
```
Manejo de errores/fallback:
- Extracción: si falla la primera llamada (timeout/payload), se reprocesa con DPI reducido y límite de páginas (≤5).
- Escritura CSV: filas de error con `radicado`, `_source_pdf`, `error` cuando hay excepción.
- Logs: se emiten `logger.info/debug/exception` con contexto (`pdf`, `radicado`, métricas de tiempo y tokens).
- GUI: mantiene KPIs y barreras por fase; errores no bloquean el procesamiento de los siguientes radicados.
## Configuración y ejecución
Parámetros clave en `application/config.py`:
- `settings.imaging.pdf_dpi`: DPI para rasterizar PDF/TIFF (impacta calidad/tamaño).
- `settings.imaging.pdf_thread_count`: paralelismo al generar imágenes.
- `settings.imaging.tiff_pages`: páginas a considerar por defecto en TIFF multi-página.
Ejecución (GUI):
1) Inicie la aplicación GUI.
2) Seleccione la carpeta raíz de radicados (cada radicado contiene PDFs/TIFFs).
3) Opcional: defina palabras clave de búsqueda (por defecto: `Factura_O_Documento_Equivalente, factura`).
4) Opcional: defina campos a extraer (si se deja vacío se abrirá el selector).
5) Opcional: elija ruta de salida para el CSV.
6) Presione “Procesar”.
Exportación CSV:
- Si se especifica `csv_output_path`, el servicio añadirá filas incrementalmente conforme procesa cada documento.
- Los encabezados se generan en la primera escritura; si existe un CSV previo con contenido, se preserva su encabezado.
## Extensibilidad
Nuevos perfiles, estrategias de extracción o ajustes en normalización y costos se pueden incorporar con cambios locales y mínimos acoplamientos.
- Perfiles (`application/profiles.py`): agregue una entrada en `PROFILES` definiendo `default_instruction`, `aggregate_list_paths`, etc.
- Estrategias de extracción (`application/strategies.py`): implemente `InvoiceExtractorStrategy.extract(images_b64, instruction)` para soportar nuevos backends/LLMs.
- Normalización (`application/normalizer.py`): adapte `normalize_to_schema` para modelos y políticas de mapeo; los costos por fase se calculan en el servicio.
- Prompts (`application/prompts.py`): `PromptBuilder.build(selected_fields, schema)` puede ajustarse para controlar la precisión y tamaño del prompt.
- Estimación de costos (`application/service.py::estimate_tokens_and_cost`): modifique tarifas/modelos según su despliegue.
Preferencia de diseño:
- Siempre que sea posible, se recomienda ajustar el prompt para conseguir un JSON estable en vez de depender de mapeos/alias complejos en código [[memory:10659104]].
## Registro y métricas
La app registra progreso, tiempos y costos por fase y por documento. Los logs se escriben en `logs/*` y la GUI muestra KPIs agregados.
-15
View File
@@ -1,15 +0,0 @@
# Documentación de `application/`
## Índice
- [`service.py`](./service.md): fachada de procesamiento de facturas.
- [`normalizer.py`](./normalizer.md): normalización LLM a esquema canónico.
- [`strategies.py`](./strategies.md): estrategias de extracción (OpenAI/Ollama).
- [`profiles.py`](./profiles.md): perfiles e instrucciones por defecto.
- [`prompts.py`](./prompts.md): construcción de prompts.
- [`config.py` y `logging_config.py`](./config.md): configuración y logging (unificado).
## Relaciones entre módulos
- `service` usa `strategies` para extraer, `profiles` para instrucciones por defecto, `prompts` para prompts reducidos y `normalizer` cuando se requiere adaptar al esquema canónico.
- `config` provee `settings` consumido por varios módulos; `logging_config` inicializa logging consistente.
-36
View File
@@ -1,36 +0,0 @@
# `application/config.py` y `application/logging_config.py`
## Propósito
Centralizan configuración (Pydantic Settings) y logging en formato JSON.
## Configuración (`application/config.py`)
### Clases
- `ApiSettings`: `base_url`, `api_key`, `model`, `request_timeout_s`.
- `ImagingSettings`: `pdf_dpi`, `pdf_thread_count`, `tiff_pages`, y parámetros judiciales.
- `RetrySettings`: reintentos y backoff.
- `AppSettings`: agrega las anteriores y `csv_delimiter`.
Se expone `settings = AppSettings()`. Lee variables desde `.env` (si existe).
### Variables de entorno relevantes
- `EXTRACTOR_BASE_URL`, `EXTRACTOR_API_KEY`, `EXTRACTOR_MODEL`, `EXTRACTOR_TIMEOUT_S`
- `PDF_DPI`, `PDF_THREAD_COUNT`, `TIFF_PAGES`
- `JUDICIAL_FAST_FIND_DPI`, `JUDICIAL_MAIN_DPI`, `JUDICIAL_GRAYSCALE`, `JUDICIAL_VALIDATE_IMAGES`
- `RETRY_MAX_ATTEMPTS`, `RETRY_WAIT_MIN_S`, `RETRY_WAIT_MAX_S`
- `CSV_DELIMITER`
## Logging (`application/logging_config.py`)
Configura loggers rotativos unificados:
- `logs/ips_extractor.log` (aplicación),
- `logs/llm.log` (normalización),
- `logs/api_calls.log` (llamadas a APIs),
y consola para WARNING+.
### Uso
```python
from application.logging_config import configure_logging
configure_logging()
```
-89
View File
@@ -1,89 +0,0 @@
# `application/flows.py`
## Propósito
Orquesta el procesamiento por radicado para:
- Calcular encabezados CSV unificados a partir de muestras y selección de campos.
- Extraer información de factura y, opcionalmente, preformato.
- Escribir filas en CSV y emitir JSON a la interfaz gráfica mediante callbacks.
Incluye una heurística para inferir qué listas deben expandirse por filas y cuáles agregarse como columnas (en preformato).
## API pública
### `compute_csv_header_union(...) -> tuple[list[str] | None, dict[str, str]]`
Calcula el encabezado CSV común (unión) desde datos de muestra y paths seleccionados, aplicando alias y resolviendo duplicados.
Parámetros clave:
- `sample_fact`, `sample_pre`: ejemplos (dict o compatible) para derivar columnas.
- `selected_fields_fact`, `selected_fields_pre`: paths seleccionados por documento.
- `pre_enabled`: habilita el cálculo para preformato.
- `alias_map_fact`, `alias_map_pre`: alias opcionales path->nombre.
Devuelve:
- Lista de encabezados finales (o `None` si no hay columnas).
- Mapa `path -> alias final` aplicado.
Notas:
- `radicado` siempre se incluye como columna.
- Duplicados de alias se desambiguarán con sufijos `(n)`.
### `process_radicados_parallel(...) -> None`
Procesa en paralelo los radicados, realizando:
1) Extracción de datos de factura y preformato (si está habilitado).
2) Emisión de JSON via `on_output`.
3) Escritura incremental en CSV con encabezado dado (`csv_header_union`) o derivado.
Parámetros clave:
- `service`: Debe exponer `extract_full_from_pdf(...)` y `_append_rows_to_csv(...)`.
- `root`, `keywords`, `pre_keywords`: Búsqueda de documentos por radicado.
- `selected_fields_fact`, `selected_fields_pre`: paths de salida.
- `csv_path`: Archivo CSV destino (si `None`, no escribe).
- `on_output`: Callback que recibe texto JSON por radicado.
- `on_progress`: Callback `(radicado, done, total)` para progreso.
- `instruction_invoice`, `instruction_pre`: instrucciones LLM.
- `alias_final_map`: alias finales a aplicar a encabezados/filas.
Concurrencia:
- Usa `ThreadPoolExecutor` con `max_workers=4` para balancear IO/CPU.
- Los errores por documento se capturan y se registran sin detener el flujo.
## Consideraciones
- Si se define `csv_header_union`, éste manda sobre encabezados derivados por radicado.
- Para preformato, la heurística `_infer_aggregate_list_paths_from_selected` selecciona la lista principal y las listas a agregar; si no hay inferencia, se consulta `profiles`.
- `on_output` y `on_progress` son opcionales y tolerantes a errores (capturados).
## Ejemplos
### Cálculo de encabezado
```python
from application.flows import compute_csv_header_union
header, alias_map = compute_csv_header_union(
sample_fact={'radicado': 'x', 'items': [{'codigo': 'A1', 'cantidad': 2}]},
sample_pre=None,
selected_fields_fact=['radicado', 'items/*/codigo', 'items/*/cantidad'],
selected_fields_pre=None,
pre_enabled=False,
)
# header ~= ['radicado', 'items/*/codigo', 'items/*/cantidad']
```
### Proceso por radicado (escritura CSV)
```python
from application.flows import process_radicados_parallel
process_radicados_parallel(
service=my_service, # Debe implementar extract_full_from_pdf y _append_rows_to_csv
root="C:/carpeta/raiz",
keywords=["FACTURA", "RADICADO"],
pre_enabled=True,
pre_keywords=["PREFORMATO"],
selected_fields_fact=['radicado', 'items/*/codigo', 'items/*/cantidad'],
selected_fields_pre=['radicado', 'dx/*/descripcion'],
csv_path="salida.csv",
on_output=lambda txt: print(txt),
on_progress=lambda r,d,t: print(f"{d}/{t} -> {r}"),
)
```
-28
View File
@@ -1,28 +0,0 @@
# `application/normalizer.py`
## Propósito
Normaliza un JSON de entrada al esquema canónico basado en rutas (paths) seleccionado por el usuario, utilizando un LLM.
## API pública
### `build_canonical_template(paths: list[str]) -> dict`
Convierte rutas del tipo `a/b/*/c` en un objeto anidado con listas y `null`, siempre incluyendo `radicado` en la raíz.
### `normalize_to_schema(data: dict, paths: list[str], profile_name: str, model: str | None = None) -> dict | None`
Usa un LLM para mapear `data` al template canónico generado por `build_canonical_template`. Retorna `None` si no hay API Key o si el LLM falla.
## Consideraciones
- Control de reintentos y timeouts vía `settings.retry` y `settings.api`.
- Mantiene `radicado` si estaba presente en el `data` original.
- Registra métricas (duración, modelo, base_url) sin exponer contenido sensible.
## Ejemplo
```python
from application.normalizer import build_canonical_template, normalize_to_schema
paths = ["radicado", "items/*/codigo", "items/*/cantidad"]
template = build_canonical_template(paths)
normalized = normalize_to_schema({"radicado": "R-1", "items": [{"codigo": "A", "cantidad": 2}]}, paths, profile_name="invoice")
```
-26
View File
@@ -1,26 +0,0 @@
# `application/profiles.py`
## Propósito
Define perfiles por tipo de documento con:
- `wrap_root`: clave raíz para envolver salidas.
- `default_instruction`: instrucción por defecto para el LLM.
- `aggregate_list_paths`: rutas de listas a agregar en CSV cuando corresponda.
## API pública
### `get_profile(name: str) -> Profile`
Devuelve un perfil por nombre. Si no existe, usa `invoice`.
## Perfiles
- `invoice`: sin `wrap_root`, instrucción para tablas y etiquetas.
## Ejemplo
```python
from application.profiles import get_profile
prof = get_profile("invoice")
inst = prof["default_instruction"]
agg = prof.get("aggregate_list_paths")
```
-28
View File
@@ -1,28 +0,0 @@
# `application/prompts.py`
## Propósito
Centraliza la construcción de prompts para:
- Extracción reducida limitada a los campos seleccionados.
- Normalización de JSON al esquema canónico.
## API pública
### `PromptBuilder.build(selected_fields, schema, profile_name=None) -> str`
Devuelve un JSON de instrucciones para extracción, respetando exactamente las claves solicitadas.
### `PromptBuilder.build_normalization_messages(profile_name, template, source_json) -> list[dict[str, str]]`
Construye los mensajes `system` y `user` para solicitar al LLM la normalización al `template`.
## Ejemplo
```python
from application.prompts import PromptBuilder
prompt = PromptBuilder.build(
["radicado", "items/*/codigo"],
{"radicado": "str", "items/*/codigo": "str"},
profile_name="invoice"
)
msgs = PromptBuilder.build_normalization_messages("invoice", {"radicado": None}, {"radicado": "R-1"})
```
-53
View File
@@ -1,53 +0,0 @@
# `application/service.py`
## Propósito
Fachada de alto nivel para procesar facturas:
- Convierte documentos (PDF/TIFF) a imágenes base64 según configuración.
- Llama a una estrategia de extracción multimodal (OpenAI/Ollama compatible).
- Construye un esquema a partir del primer documento y permite seleccionar campos.
- Escribe CSV incrementalmente respetando la selección de paths.
## API pública
### `InvoiceProcessingService.extract_full_from_pdf(pdf_path, instruction=None) -> dict`
Extrae JSON completo desde un documento. Selecciona instrucción por defecto del perfil `invoice` si no se provee y aplica fallback (DPI reducido, límite de páginas) ante fallos del backend.
Parámetros:
- `pdf_path`: ruta al documento.
- `instruction`: instrucción (prompt) opcional.
### `InvoiceProcessingService.build_schema_from_first(pdf_path, ...) -> (schema, data)`
Construye un esquema de rutas a partir del primer documento y devuelve también los datos extraídos.
### `InvoiceProcessingService.process_root_with_field_selection(root_path, keywords=None, *, doc_types=None, fields=None, on_progress=None, csv_output_path=None, csv_header_override=None, instruction_override=None, on_usage=None, alias_map=None) -> list[dict]`
Procesa radicados en un directorio aplicando una selección de campos. Si `fields` es `None`, devuelve un objeto con `_needs_fields` para que el llamador abra el selector de campos.
#### Selección de archivos por keywords/patrones
- En modo sencillo (`keywords`), las palabras se interpretan como:
- Texto sin `*`/`?`: coincide si el nombre de archivo **contiene** ese texto (case-insensitive).
- Patrón con `*`/`?`: se evalúa usando semántica tipo Windows (`*_1`, `*_OK?.pdf`, etc.).
- En modo multi-documento (`doc_types`), cada tipo tiene su propio keyword/patrón con la misma semántica.
- Si el patrón no incluye extensión, se aplica sobre el nombre sin extensión y solo se consideran `.pdf`, `.tif`, `.tiff`.
- Si incluye una extensión válida (por ejemplo `.pdf` en `*_OK?.pdf`), se respeta exactamente esa extensión.
## Consideraciones
- La escritura CSV respeta el orden de `csv_header_override` si se proporciona.
- Se incluyen logs detallados de tiempos y tamaños para diagnosticar problemas.
## Ejemplos
```python
from application.service import InvoiceProcessingService
from application.strategies import OllamaExtractor
svc = InvoiceProcessingService(OllamaExtractor())
schema, first = svc.build_schema_from_first("ejemplo.pdf")
resultados = svc.process_root_with_field_selection(
root_path="C:/facturas",
keywords=["FACTURA"],
fields=["radicado", "items/*/codigo", "items/*/cantidad"],
csv_output_path="salida.csv",
)
```
-31
View File
@@ -1,31 +0,0 @@
# `application/strategies.py`
## Propósito
Define la interfaz `InvoiceExtractorStrategy` y una implementación `OllamaExtractor` para realizar llamadas a un backend compatible con OpenAI/Ollama que procese imágenes + texto.
## API pública
### `class InvoiceExtractorStrategy(ABC)`
Interfaz para extraer datos a partir de páginas en base64 e instrucción de texto.
### `class OllamaExtractor(InvoiceExtractorStrategy)`
Extractor que construye prompts multimodales y llama a `chat.completions.create`.
Métodos clave:
- `extract(images_b64: list[str], instruction: str) -> dict`: devuelve al menos `{"raw": ...}` con el texto del modelo.
- `update_config(api_key=None, base_url=None, model=None)`: actualiza y recrea el cliente interno.
## Consideraciones
- Reintentos con `tenacity` usando la configuración de `settings.retry`.
- Registro de métricas (tokens, tiempos) en `application.api_calls` y logs propios.
## Ejemplo
```python
from application.strategies import OllamaExtractor
ext = OllamaExtractor()
res = ext.extract([img_b64_1, img_b64_2], "Instrucción...")
text = res["raw"]
```
-12
View File
@@ -1,12 +0,0 @@
# Documentación de `gui/`
## Índice
- [`controller.py`](./controller.md): controlador principal de la GUI.
- [`field_selector.py`](./field_selector.md): diálogo de selección de campos.
- [`view.py`](./view.md): vista principal `customtkinter`.
## Relaciones entre módulos
- `controller` coordina `view` y llama a servicios de `application`; abre `field_selector` cuando se requieren campos.
- `view` expone callbacks, getters y helpers seguros para escribir salida desde hilos.
-32
View File
@@ -1,32 +0,0 @@
# `gui/controller.py`
## Propósito
Controlador principal de la GUI: enlaza la vista con los servicios de aplicación, maneja selección de carpetas/CSV, abre el selector de campos y ejecuta el procesamiento por radicado.
## API pública
### `class AppController`
- `__init__(view, service)`: inicializa y vincula callbacks.
- `on_select_root()`: abre diálogo de carpeta para la raíz.
- `on_select_csv()`: abre diálogo para guardar CSV.
- `on_process()`: coordina selección de campos y el procesamiento por radicado.
## Flujo
1) Construye schema/muestra de factura cuando es necesario.
2) Abre `FieldSelectorDialog` para elegir campos (y alias).
3) Relanza el procesamiento por radicado, emite JSON y escribe CSV incremental.
## Ejemplo
```python
from gui.view import InvoiceAppView
from gui.controller import AppController
from application.service import InvoiceProcessingService
from application.strategies import OllamaExtractor
view = InvoiceAppView()
svc = InvoiceProcessingService(OllamaExtractor())
controller = AppController(view, svc)
view.mainloop()
```
-24
View File
@@ -1,24 +0,0 @@
# `gui/field_selector.py`
## Propósito
Diálogo modal para buscar y seleccionar paths del schema, con la posibilidad de asignar alias para los encabezados del CSV.
## API pública
### `class FieldSelectorDialog`
- `__init__(master, schema, sample=None, initial_selected=None)`: construye UI.
- `set_on_submit(callback)`: registra callback `(selected_fields, alias_map)`.
## Interacciones
- Búsqueda en vivo por texto.
- Seleccionar todo / limpiar.
- Vista de muestra JSON del primer documento.
- Validación de alias duplicados.
## Ejemplo
```python
dlg = FieldSelectorDialog(parent, schema, sample)
dlg.set_on_submit(lambda fields, aliases: print(fields, aliases))
```
-30
View File
@@ -1,30 +0,0 @@
# `gui/view.py`
## Propósito
Vista principal basada en `customtkinter` que define la interfaz de procesamiento y expone métodos para enlazar callbacks y comunicar estado.
## API pública
### `class InvoiceAppView`
- `set_on_select_root(cb)`, `set_on_process(cb)`, `set_on_select_csv(cb)`
- Getters: `get_root_path()`, `get_keywords()`, `get_fields()`, `get_csv_path()`
- Output: `append_output(text)`, `append_output_safe(text)`
- Estado: `set_processing(bool)`
### Keywords / patrones de archivos
- En la pestaña principal, cada Tipo de Documento tiene un campo de **Keyword/Patrón**.
- Se aceptan tanto palabras simples (sin `*` ni `?`) como patrones tipo Windows con `*` y `?`.
- Si el patrón **no incluye extensión**, se aplica sobre el nombre sin extensión y solo se consideran archivos `.pdf`, `.tif`, `.tiff`.
- Si el patrón **incluye extensión** válida (p. ej. `*_OK?.pdf`), se aplica sobre el nombre completo respetando esa extensión.
## Ejemplo
```python
from gui.view import InvoiceAppView
view = InvoiceAppView()
view.set_on_process(lambda: print("Procesar!"))
view.mainloop()
```
+6 -25
View File
@@ -18,7 +18,7 @@ from application.preclassification import PreclassificationService
from application.strategies import OllamaExtractor
from application.database import DatabaseManager
from application.config import settings
from application.costs import compute_cost_from_usage, MODEL_PRICING, sum_costs
from application.costs import MODEL_PRICING
import pandas as pd
@@ -168,8 +168,7 @@ class AppController:
selected = fd.askdirectory()
if not selected:
return
self.view.root_entry.delete(0, 'end')
self.view.root_entry.insert(0, selected)
self.view.set_root_path(selected)
# Detectar si el primer documento a procesar es TIFF para esta carpeta
try:
@@ -195,26 +194,15 @@ class AppController:
selected = fd.askdirectory()
if not selected:
return
try:
self.view.preclass_root_entry.delete(0, 'end')
self.view.preclass_root_entry.insert(0, selected)
except Exception:
# fallback si cambia el nombre del widget
try:
setter = getattr(self.view, "set_preclass_root_path", None)
if callable(setter):
setter(selected)
except Exception:
pass
self.view.set_preclass_root_path(selected)
def on_run_preclassification(self):
"""Ejecuta la preclasificación en un hilo separado."""
# 1. Leer inputs de la UI
root = self.view.get_preclass_root_path()
mode = getattr(self.view, "get_preclass_mode", lambda: "radicados")()
patterns = self.view.get_preclass_patterns()
target_info = self.view.get_preclass_target_info()
page_range = self.view.get_preclass_range() # (enabled, start, end)
page_range = self.view.get_preclass_range()
mode = self.view.get_preclass_mode()
# Validaciones básicas
@@ -359,15 +347,9 @@ class AppController:
# --- Pestaña Resultados ---
def on_res_select(self, event):
"""Muestra detalles del resultado seleccionado."""
selection = self.view.tree_results.selection()
if not selection:
return
# values: (ID, Run, Fecha, Radicado, Estado, Archivo)
item_vals = self.view.tree_results.item(selection[0], "values")
item_vals = self.view.get_selected_result_values()
if not item_vals:
return
try:
res_id = int(item_vals[0])
matches = self.db_manager.get_matches_for_result(res_id)
@@ -464,8 +446,7 @@ class AppController:
"""Abre un diálogo de guardado y establece la ruta del CSV de salida."""
selected = fd.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV", "*.csv"), ("Todos", "*.*")])
if selected:
self.view.csv_entry.delete(0, 'end')
self.view.csv_entry.insert(0, selected)
self.view.set_csv_path(selected)
def on_process(self):
"""Inicia el flujo de procesamiento por radicado (solo facturas) en un hilo de fondo.
+15 -1
View File
@@ -25,6 +25,8 @@ class MainProcessingTab:
self._create_config_section()
self._create_process_button()
self._create_output_section()
# Conectar el trace después de que todos los widgets existen
self.mode_var.trace_add("write", lambda *_: self.update_ui_for_mode())
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
@@ -297,10 +299,22 @@ class MainProcessingTab:
def get_root_path(self) -> str:
"""Devuelve la ruta raíz seleccionada."""
return self.root_entry.get().strip()
def set_root_path(self, path: str) -> None:
"""Establece la ruta raíz."""
self.root_entry.configure(state="normal")
self.root_entry.delete(0, "end")
self.root_entry.insert(0, path)
def get_csv_path(self) -> str:
"""Devuelve la ruta del CSV."""
return self.csv_entry.get().strip()
def set_csv_path(self, path: str) -> None:
"""Establece la ruta del CSV."""
self.csv_entry.configure(state="normal")
self.csv_entry.delete(0, "end")
self.csv_entry.insert(0, path)
def get_process_mode(self) -> str:
"""Devuelve el modo de procesamiento: 'radicados' o 'general'."""
+4 -2
View File
@@ -3,8 +3,11 @@ import customtkinter as ctk
import tkinter.ttk as ttk
from gui.styles import AppStyles
# matplotlib es opcional
# matplotlib y numpy son opcionales
try:
import matplotlib
matplotlib.use("Agg") # backend no interactivo para render en canvas Tk
import numpy as np
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
HAS_MPL = True
@@ -226,7 +229,6 @@ class MetricsTab:
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
+7 -1
View File
@@ -388,7 +388,13 @@ class PreclassificationTab:
def get_root_path(self) -> str:
"""Devuelve la ruta raíz de preclasificación."""
return self.preclass_root_entry.get().strip()
def set_root_path(self, path: str) -> None:
"""Establece la ruta raíz de preclasificación."""
self.preclass_root_entry.configure(state="normal")
self.preclass_root_entry.delete(0, "end")
self.preclass_root_entry.insert(0, path)
def get_mode(self) -> str:
"""Devuelve el modo de preclasificación: 'radicados' o 'general'."""
try:
+7
View File
@@ -202,6 +202,13 @@ class ResultsTab:
def set_on_select_result(self, callback):
"""Vincula el callback al evento de selección de fila."""
self.tree_results.bind("<<TreeviewSelect>>", callback)
def get_selected_result_values(self) -> tuple | None:
"""Devuelve los valores de la fila seleccionada o None si no hay selección."""
selection = self.tree_results.selection()
if not selection:
return None
return self.tree_results.item(selection[0], "values") or None
def update_matches_table(self, rows):
"""Actualiza la tabla de matches con los datos proporcionados."""
+13 -13
View File
@@ -3,19 +3,11 @@
Expone métodos para enlazar callbacks, leer entradas y escribir salida en un
área de texto, además de habilitar/deshabilitar controles durante el proceso.
"""
import os # Necesario para basename en update_res_table
import os
import customtkinter as ctk
from gui.styles import AppStyles
from gui.tabs import SettingsTab, ResultsTab, MainProcessingTab, PreclassificationTab, MetricsTab
# matplotlib es opcional
try:
import matplotlib
matplotlib.use("Agg") # backend no interactivo para render en canvas
HAS_MPL = True
except Exception:
HAS_MPL = False
class InvoiceAppView(ctk.CTk):
"""Ventana principal con pestañas de Procesar y Configuración."""
@@ -160,13 +152,21 @@ class InvoiceAppView(ctk.CTk):
def append_preclass_output_safe(self, text: str) -> None:
self.after(0, lambda: self.preclass_tab.append_output_safe(text))
def _update_ui_for_mode(self):
"""Actualiza la visibilidad de componentes según el modo seleccionado."""
self.main_tab.update_ui_for_mode()
def get_root_path(self) -> str:
return self.main_tab.get_root_path()
def set_root_path(self, path: str) -> None:
self.main_tab.set_root_path(path)
def set_csv_path(self, path: str) -> None:
self.main_tab.set_csv_path(path)
def set_preclass_root_path(self, path: str) -> None:
self.preclass_tab.set_root_path(path)
def get_selected_result_values(self) -> tuple | None:
return self.results_tab.get_selected_result_values()
def get_process_mode(self) -> str:
"""Devuelve el modo de procesamiento: 'radicados' o 'general'."""
return self.main_tab.get_process_mode()