diff --git a/application/batch_processor.py b/application/batch_processor.py index c7672f9..9813438 100644 --- a/application/batch_processor.py +++ b/application/batch_processor.py @@ -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, diff --git a/application/costs.py b/application/costs.py index a3b4bc5..82205b9 100644 --- a/application/costs.py +++ b/application/costs.py @@ -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 diff --git a/application/database.py b/application/database.py index b0ceaf9..c558dd7 100644 --- a/application/database.py +++ b/application/database.py @@ -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) diff --git a/application/estimator.py b/application/estimator.py index f496f05..e8637de 100644 --- a/application/estimator.py +++ b/application/estimator.py @@ -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) diff --git a/application/normalizer.py b/application/normalizer.py index c30ab0c..81eee4e 100644 --- a/application/normalizer.py +++ b/application/normalizer.py @@ -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), }, diff --git a/application/preclassification.py b/application/preclassification.py index 5a693d5..be5e694 100644 --- a/application/preclassification.py +++ b/application/preclassification.py @@ -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) diff --git a/application/prompts.py b/application/prompts.py index 0999803..7c30db8 100644 --- a/application/prompts.py +++ b/application/prompts.py @@ -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) diff --git a/application/service.py b/application/service.py index b217368..734e318 100644 --- a/application/service.py +++ b/application/service.py @@ -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( diff --git a/application/strategies.py b/application/strategies.py index 0b1e8d9..924d0ba 100644 --- a/application/strategies.py +++ b/application/strategies.py @@ -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: diff --git a/core/pdf_utils.py b/core/pdf_utils.py index 31262ce..4d7a445 100644 --- a/core/pdf_utils.py +++ b/core/pdf_utils.py @@ -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}") diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index faa4964..0000000 --- a/docs/README.md +++ /dev/null @@ -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
(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. - - diff --git a/docs/application/README.md b/docs/application/README.md deleted file mode 100644 index 0ba75d7..0000000 --- a/docs/application/README.md +++ /dev/null @@ -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. - - diff --git a/docs/application/config.md b/docs/application/config.md deleted file mode 100644 index 5ad33dc..0000000 --- a/docs/application/config.md +++ /dev/null @@ -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() -``` - - diff --git a/docs/application/flows.md b/docs/application/flows.md deleted file mode 100644 index 2e6fbd8..0000000 --- a/docs/application/flows.md +++ /dev/null @@ -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}"), -) -``` - - diff --git a/docs/application/normalizer.md b/docs/application/normalizer.md deleted file mode 100644 index cb81e50..0000000 --- a/docs/application/normalizer.md +++ /dev/null @@ -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") -``` - - diff --git a/docs/application/profiles.md b/docs/application/profiles.md deleted file mode 100644 index 388c7af..0000000 --- a/docs/application/profiles.md +++ /dev/null @@ -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") -``` - - diff --git a/docs/application/prompts.md b/docs/application/prompts.md deleted file mode 100644 index 0df25ea..0000000 --- a/docs/application/prompts.md +++ /dev/null @@ -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"}) -``` - - diff --git a/docs/application/service.md b/docs/application/service.md deleted file mode 100644 index 6bd58f2..0000000 --- a/docs/application/service.md +++ /dev/null @@ -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", -) -``` - - diff --git a/docs/application/strategies.md b/docs/application/strategies.md deleted file mode 100644 index a6b9dc5..0000000 --- a/docs/application/strategies.md +++ /dev/null @@ -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"] -``` - - diff --git a/docs/gui/README.md b/docs/gui/README.md deleted file mode 100644 index d458a81..0000000 --- a/docs/gui/README.md +++ /dev/null @@ -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. - - diff --git a/docs/gui/controller.md b/docs/gui/controller.md deleted file mode 100644 index 75cf206..0000000 --- a/docs/gui/controller.md +++ /dev/null @@ -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() -``` - - diff --git a/docs/gui/field_selector.md b/docs/gui/field_selector.md deleted file mode 100644 index 34c4129..0000000 --- a/docs/gui/field_selector.md +++ /dev/null @@ -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)) -``` - - diff --git a/docs/gui/view.md b/docs/gui/view.md deleted file mode 100644 index b8d4355..0000000 --- a/docs/gui/view.md +++ /dev/null @@ -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() -``` - - diff --git a/gui/controller.py b/gui/controller.py index b556803..92a96ec 100644 --- a/gui/controller.py +++ b/gui/controller.py @@ -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. diff --git a/gui/tabs/main_tab.py b/gui/tabs/main_tab.py index 0532211..27c4b1a 100644 --- a/gui/tabs/main_tab.py +++ b/gui/tabs/main_tab.py @@ -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'.""" diff --git a/gui/tabs/metrics_tab.py b/gui/tabs/metrics_tab.py index 0f6ff07..09dde31 100644 --- a/gui/tabs/metrics_tab.py +++ b/gui/tabs/metrics_tab.py @@ -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 diff --git a/gui/tabs/preclass_tab.py b/gui/tabs/preclass_tab.py index b688aab..87cb093 100644 --- a/gui/tabs/preclass_tab.py +++ b/gui/tabs/preclass_tab.py @@ -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: diff --git a/gui/tabs/results_tab.py b/gui/tabs/results_tab.py index ee95aa4..4b7c82b 100644 --- a/gui/tabs/results_tab.py +++ b/gui/tabs/results_tab.py @@ -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("<>", 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.""" diff --git a/gui/view.py b/gui/view.py index a762048..7bb4f4c 100644 --- a/gui/view.py +++ b/gui/view.py @@ -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()