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()