refactor: Introduce accumulate_usage function for token aggregation, enhance alias map expansion in BatchProcessor, and streamline error handling in PreclassificationService. Update PDF processing utilities for improved image conversion and error logging.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional, Tuple, Callable
|
||||
import re
|
||||
import sqlite3
|
||||
import shutil
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional, Tuple, Callable
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from application.strategies import OllamaExtractor
|
||||
from application.database import DatabaseManager
|
||||
from core.pdf_utils import document_to_images_bs64
|
||||
from core.utils import collect_radicado_docs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import shutil
|
||||
import pandas as pd
|
||||
|
||||
class PreclassificationService:
|
||||
def __init__(self, extractor: OllamaExtractor):
|
||||
self.extractor = extractor
|
||||
@@ -77,21 +77,11 @@ class PreclassificationService:
|
||||
Organiza los archivos MATCH en carpetas jerárquicas.
|
||||
Devuelve (total_copiados, total_errores).
|
||||
"""
|
||||
# Obtener modo del Run
|
||||
# Necesitamos leer config del Run
|
||||
conn = sqlite3.connect(self.db.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT config FROM runs WHERE id = ?", (run_id,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
run = self.db.get_run(run_id)
|
||||
mode = "radicados"
|
||||
if row and row[0]:
|
||||
try:
|
||||
cfg = json.loads(row[0])
|
||||
mode = str(cfg.get("mode", "radicados") or "radicados").strip().lower()
|
||||
except Exception:
|
||||
mode = "radicados"
|
||||
if run:
|
||||
cfg = run.get("config") or {}
|
||||
mode = str(cfg.get("mode", "radicados") or "radicados").strip().lower()
|
||||
if mode not in ("radicados", "general"):
|
||||
mode = "radicados"
|
||||
|
||||
@@ -232,11 +222,7 @@ class PreclassificationService:
|
||||
])
|
||||
|
||||
total = len(radicados)
|
||||
if mode == "general":
|
||||
# Si es general, quizás queramos reportar progreso por archivo, no por carpeta "."
|
||||
# Pero _identify_files nos da los archivos.
|
||||
pass
|
||||
|
||||
|
||||
# Preparar prompt
|
||||
keys_to_extract = [p["key"] for p in patterns]
|
||||
prompt = self._build_prompt(keys_to_extract)
|
||||
@@ -281,9 +267,7 @@ class PreclassificationService:
|
||||
else:
|
||||
current_radicado = base_name
|
||||
|
||||
# --- CORRECCIÓN FORZADA: Si sigue siendo un punto o vacío, usar timestamp o hash ---
|
||||
if str(current_radicado).strip() in (".", ""):
|
||||
import time
|
||||
current_radicado = f"FILE_{int(time.time()*1000)}_{f_idx}"
|
||||
|
||||
logger.info(f"Procesando archivo [{f_idx}/{total_files}]: {file_path} -> Radicado: {current_radicado}")
|
||||
@@ -296,22 +280,7 @@ class PreclassificationService:
|
||||
pass # Evitar romper el loop si la GUI falla
|
||||
|
||||
try:
|
||||
# 4. Convertir a imágenes
|
||||
# Manejo de rangos
|
||||
tiff_range = None
|
||||
if page_range[0]: # enabled
|
||||
# document_to_images_bs64 espera (start, end)
|
||||
# Nota: pdf_pages_to_images_bs64 usa start, end.
|
||||
# document_to_images_bs64 usa tiff_page_range=(start, end).
|
||||
# Para PDF hay que ver como lo manejamos si usamos document_to_images_bs64
|
||||
# La función document_to_images_bs64 no expone rango para PDF directamente,
|
||||
# pero pdf_to_images_bs64 sí via pdf_pages_to_images_bs64?
|
||||
# No, pdf_to_images_bs64 convierte todo.
|
||||
# Vamos a usar document_to_images_bs64 y si es PDF y hay rango, lo cortamos post-procesamiento o usamos función específica.
|
||||
# Mejor: document_to_images_bs64 llama a pdf_to_images_bs64 que NO filtra.
|
||||
# Debemos implementar logica de rango aqui.
|
||||
pass
|
||||
|
||||
# 4. Convertir a imágenes (con soporte de rango si está habilitado)
|
||||
images_b64 = self._get_images(file_path, page_range)
|
||||
|
||||
if not images_b64:
|
||||
@@ -324,12 +293,7 @@ class PreclassificationService:
|
||||
|
||||
# 6. Parseo y Validación
|
||||
parsed = self._parse_json_response(raw_text)
|
||||
|
||||
# Evaluar reglas
|
||||
is_match = True
|
||||
# Si no hay patrones definidos, quizás asumimos match si responde JSON válido?
|
||||
# Pero el usuario pidió validar patrones.
|
||||
|
||||
|
||||
# Guardar resultado cabecera
|
||||
result_id = self.db.add_result(run_id, current_radicado, file_path, "PROCESSED", raw_text)
|
||||
|
||||
@@ -403,10 +367,7 @@ class PreclassificationService:
|
||||
if not target_info.get("enabled"):
|
||||
return all_files
|
||||
|
||||
# Filtrar por patron
|
||||
pattern = target_info.get("pattern", "*").lower()
|
||||
# Convertir glob simple a regex o usar fnmatch
|
||||
import fnmatch
|
||||
filtered = [f for f in all_files if fnmatch.fnmatch(os.path.basename(f).lower(), pattern)]
|
||||
return filtered
|
||||
|
||||
@@ -414,25 +375,17 @@ class PreclassificationService:
|
||||
"""Convierte archivo a imagenes, aplicando rango si es necesario."""
|
||||
enabled, start, end = page_range
|
||||
|
||||
# Si rango habilitado, usar lógica especifica
|
||||
if enabled:
|
||||
from core.pdf_utils import pdf_pages_to_images_bs64
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
s, e = max(1, start), max(1, end)
|
||||
if s > e:
|
||||
s, e = e, s
|
||||
if ext == ".pdf":
|
||||
from core.pdf_utils import pdf_pages_to_images_bs64
|
||||
# pdf_pages_to_images_bs64 espera start, end (inclusive)
|
||||
# Validar indices positivos
|
||||
s = max(1, start)
|
||||
e = max(1, end)
|
||||
if s > e: s, e = e, s
|
||||
return pdf_pages_to_images_bs64(file_path, s, e, dpi=300) # DPI medio para velocidad?
|
||||
return pdf_pages_to_images_bs64(file_path, s, e, dpi=300)
|
||||
elif ext in (".tif", ".tiff"):
|
||||
# document_to_images_bs64 soporta tiff_page_range
|
||||
s = max(1, start)
|
||||
e = max(1, end)
|
||||
if s > e: s, e = e, s
|
||||
return document_to_images_bs64(file_path, tiff_page_range=(s, e), dpi=300)
|
||||
|
||||
# Sin rango
|
||||
|
||||
return document_to_images_bs64(file_path, dpi=300)
|
||||
|
||||
def _build_prompt(self, keys: List[str]) -> str:
|
||||
@@ -460,11 +413,4 @@ Campos a buscar: {keys_str}"""
|
||||
return {}
|
||||
|
||||
def _update_result_status(self, result_id: int, status: str):
|
||||
# Actualización hacky directa porque DatabaseManager no tiene update expuesto
|
||||
# Podría agregar update_result en DatabaseManager, pero por brevedad:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(self.db.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE results SET status = ? WHERE id = ?", (status, result_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.db.update_result_status(result_id, status)
|
||||
|
||||
+50
-60
@@ -13,40 +13,54 @@ from typing import Dict, List, Any
|
||||
from .profiles import get_profile
|
||||
|
||||
|
||||
_INSTRUCCIONES = [
|
||||
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
|
||||
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
|
||||
"No inventes claves nuevas ni cambies nombres existentes",
|
||||
"Si un campo no existe, usa null o [] según corresponda",
|
||||
"Ten especial cuidado en no confundir el número de autorización con el código",
|
||||
]
|
||||
|
||||
_REGLAS_NOMENCLATURA = [
|
||||
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
|
||||
"No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)",
|
||||
"Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}",
|
||||
"No cambies nombres de claves por 'equivalentes'",
|
||||
"Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado",
|
||||
]
|
||||
|
||||
_NOTA_LISTAS = "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}"
|
||||
|
||||
|
||||
class PromptBuilder:
|
||||
@staticmethod
|
||||
def build(selected_fields: List[str], schema: Dict[str, str], profile_name: str | None = None) -> str:
|
||||
def _base_payload(
|
||||
profile_name: str,
|
||||
fields: List[str],
|
||||
schema: Dict[str, str],
|
||||
extra_meta: Dict[str, Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
profile = get_profile(profile_name or "invoice")
|
||||
field_schema = {k: schema[k] for k in selected_fields if k in schema}
|
||||
json_prompt: Dict[str, Any] = {
|
||||
"meta": {
|
||||
"perfil": profile.get("name", "invoice"),
|
||||
"descripcion": "Extracción estructurada desde OCR de facturas (sin datos personales reales)",
|
||||
},
|
||||
"instrucciones": [
|
||||
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
|
||||
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
|
||||
"No inventes claves nuevas ni cambies nombres existentes",
|
||||
"Si un campo no existe, usa null o [] según corresponda",
|
||||
"Ten especial cuidado en no confundir el número de autorización con el código"
|
||||
],
|
||||
"campos_requeridos": selected_fields,
|
||||
"schema_referencia": field_schema,
|
||||
"reglas_estrictas_nomenclatura": [
|
||||
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
|
||||
"No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)",
|
||||
"Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}",
|
||||
"No cambies nombres de claves por 'equivalentes'",
|
||||
"Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado",
|
||||
],
|
||||
"salida": {
|
||||
"tipo": "json_object",
|
||||
"raiz": None,
|
||||
},
|
||||
"nota_listas": "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}",
|
||||
meta: Dict[str, Any] = {
|
||||
"perfil": profile.get("name", "invoice"),
|
||||
"descripcion": "Extracción estructurada desde OCR de facturas (sin datos personales reales)",
|
||||
}
|
||||
if extra_meta:
|
||||
meta.update(extra_meta)
|
||||
return {
|
||||
"meta": meta,
|
||||
"instrucciones": _INSTRUCCIONES,
|
||||
"campos_requeridos": fields,
|
||||
"schema_referencia": {k: schema[k] for k in fields if k in schema},
|
||||
"reglas_estrictas_nomenclatura": _REGLAS_NOMENCLATURA,
|
||||
"salida": {"tipo": "json_object", "raiz": None},
|
||||
"nota_listas": _NOTA_LISTAS,
|
||||
}
|
||||
return json.dumps(json_prompt, indent=2, ensure_ascii=False)
|
||||
|
||||
@staticmethod
|
||||
def build(selected_fields: List[str], schema: Dict[str, str], profile_name: str | None = None) -> str:
|
||||
payload = PromptBuilder._base_payload(profile_name or "invoice", selected_fields, schema)
|
||||
return json.dumps(payload, indent=2, ensure_ascii=False)
|
||||
|
||||
@staticmethod
|
||||
def build_normalization_messages(profile_name: str, template: Dict[str, Any], source_json: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
@@ -81,35 +95,11 @@ class PromptBuilder:
|
||||
|
||||
doc_type_name se usa en meta para trazabilidad; no afecta el esquema.
|
||||
"""
|
||||
profile = get_profile(profile_name or "invoice")
|
||||
field_schema = {k: schema_for_type.get(k) for k in selected_paths_for_type if k in schema_for_type}
|
||||
json_prompt: Dict[str, Any] = {
|
||||
"meta": {
|
||||
"perfil": profile.get("name", "invoice"),
|
||||
"tipo_documento": doc_type_name,
|
||||
"descripcion": "Extracción estructurada desde OCR (sin datos personales reales)",
|
||||
},
|
||||
"instrucciones": [
|
||||
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
|
||||
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
|
||||
"No inventes claves nuevas ni cambies nombres existentes",
|
||||
"Si un campo no existe, usa null o [] según corresponda",
|
||||
"Ten especial cuidado en no confundir el número de autorización con el código"
|
||||
],
|
||||
"campos_requeridos": selected_paths_for_type,
|
||||
"schema_referencia": field_schema,
|
||||
"reglas_estrictas_nomenclatura": [
|
||||
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
|
||||
"No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)",
|
||||
"Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}",
|
||||
"No cambies nombres de claves por 'equivalentes'",
|
||||
"Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado",
|
||||
],
|
||||
"salida": {
|
||||
"tipo": "json_object",
|
||||
"raiz": None,
|
||||
},
|
||||
"nota_listas": "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}",
|
||||
}
|
||||
return json.dumps(json_prompt, indent=2, ensure_ascii=False)
|
||||
payload = PromptBuilder._base_payload(
|
||||
profile_name or "invoice",
|
||||
selected_paths_for_type,
|
||||
schema_for_type,
|
||||
extra_meta={"tipo_documento": doc_type_name},
|
||||
)
|
||||
return json.dumps(payload, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
+78
-146
@@ -12,9 +12,6 @@ import logging
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from typing import Callable
|
||||
|
||||
from .strategies import InvoiceExtractorStrategy
|
||||
from .prompts import PromptBuilder
|
||||
from .profiles import get_profile
|
||||
@@ -51,6 +48,50 @@ class InvoiceProcessingService:
|
||||
except Exception:
|
||||
self._csv_lock = None
|
||||
|
||||
def _convert_to_images(self, pdf_path: str, dpi: int, thread_count: int, tiff_pages, tiff_page_range) -> List[str]:
|
||||
"""Convierte el documento a imágenes base64."""
|
||||
filename = os.path.basename(pdf_path)
|
||||
tiff_pages_cfg = getattr(settings.imaging, 'tiff_pages', (2, 3)) if tiff_pages is None else tiff_pages
|
||||
poppler = getattr(settings.imaging, 'poppler_path', None)
|
||||
try:
|
||||
return document_to_images_bs64(
|
||||
pdf_path, dpi=dpi, thread_count=thread_count,
|
||||
tiff_pages=tiff_pages_cfg, tiff_page_range=tiff_page_range,
|
||||
poppler_path=poppler,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
|
||||
raise
|
||||
|
||||
def _apply_extractor_fallback(self, pdf_path: str) -> List[str]:
|
||||
"""Carga imágenes con DPI reducido (máx 5 páginas) para el fallback."""
|
||||
fb_dpi = 400
|
||||
tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3))
|
||||
poppler = getattr(settings.imaging, 'poppler_path', None)
|
||||
images = document_to_images_bs64(pdf_path, dpi=fb_dpi, tiff_pages=tiff_pages_fb, poppler_path=poppler)
|
||||
return images[:5] if len(images) > 5 else images
|
||||
|
||||
def _parse_model_output(self, model_output: dict, pdf_path: str, dpi_cfg: int) -> Tuple[Any, Any]:
|
||||
"""Extrae y parsea el JSON del output del modelo."""
|
||||
filename = os.path.basename(pdf_path)
|
||||
raw_text = model_output.get("raw", "")
|
||||
usage = model_output.get("usage")
|
||||
json_text = extract_json_from_text(raw_text)
|
||||
if not json_text:
|
||||
logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path})
|
||||
raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.")
|
||||
data = json.loads(json_text)
|
||||
try:
|
||||
if isinstance(data, dict):
|
||||
top_keys = list(data.keys())
|
||||
preview = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
|
||||
logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
else:
|
||||
logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
except Exception:
|
||||
pass
|
||||
return data, usage
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
def extract_full_from_pdf(
|
||||
self,
|
||||
@@ -59,90 +100,34 @@ class InvoiceProcessingService:
|
||||
*,
|
||||
tiff_page_range: Optional[Tuple[int, int]] = None,
|
||||
history: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> Tuple[Any, Any]:
|
||||
"""Extrae un JSON completo desde un documento (PDF/TIFF) usando el extractor.
|
||||
|
||||
Estrategia: intenta con configuración por defecto. Si el backend responde
|
||||
con un error (p. ej., 500 por payload demasiado grande), reintenta con
|
||||
menor DPI y recortando a las primeras 5 páginas.
|
||||
En caso de error, reintenta con DPI reducido y máximo 5 páginas.
|
||||
"""
|
||||
filename = os.path.basename(pdf_path)
|
||||
logger.info(f"Iniciando extracción de documento: {filename}", extra={"pdf": pdf_path})
|
||||
|
||||
t0 = time.time()
|
||||
# Preparar imágenes según configuración
|
||||
dpi_cfg = settings.imaging.pdf_dpi
|
||||
thr_cfg = settings.imaging.pdf_thread_count
|
||||
images_t0 = time.time()
|
||||
tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3))
|
||||
|
||||
try:
|
||||
images_b64 = document_to_images_bs64(
|
||||
pdf_path,
|
||||
dpi=dpi_cfg,
|
||||
thread_count=thr_cfg,
|
||||
tiff_pages=tiff_pages,
|
||||
tiff_page_range=tiff_page_range,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
|
||||
raise
|
||||
|
||||
images_ms = int((time.time() - images_t0) * 1000)
|
||||
logger.debug(f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg}, ms={images_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
|
||||
# Selección de instrucción por defecto según el contexto
|
||||
profile = get_profile('invoice')
|
||||
text_instruction = instruction if instruction is not None else profile["default_instruction"]
|
||||
# Métricas del prompt (sin registrar el contenido completo por privacidad)
|
||||
inst_preview = (text_instruction[:180] + "…") if isinstance(text_instruction, str) and len(text_instruction) > 180 else text_instruction
|
||||
inst_len = len(text_instruction) if isinstance(text_instruction, str) else 0
|
||||
hist_len = len(history) if history else 0
|
||||
|
||||
logger.debug(f"Enviando a extractor {filename} (images={len(images_b64)}, prompt_len={inst_len}, history_len={hist_len})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
|
||||
try:
|
||||
call_t0 = time.time()
|
||||
model_output = self.extractor.extract(images_b64, text_instruction, history=history)
|
||||
call_ms = int((time.time() - call_t0) * 1000)
|
||||
logger.debug(f"Extractor OK para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
except Exception as e:
|
||||
logger.warning(f"Fallo en extracción por defecto para {filename}. Reintentando con DPI reducido.", extra={"pdf": pdf_path, "error": str(e)})
|
||||
# Fallback: DPI reducido y limitar páginas
|
||||
fb_dpi = 400
|
||||
tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3))
|
||||
# En fallback NO aplicamos `tiff_page_range`; se usa rango propio (pages<=5).
|
||||
images_b64_fb = document_to_images_bs64(pdf_path, dpi=fb_dpi, tiff_pages=tiff_pages_fb)
|
||||
# Limitar a 5 imágenes en fallback para controlar tamaño
|
||||
if len(images_b64_fb) > 5:
|
||||
images_b64_fb = images_b64_fb[:5]
|
||||
logger.debug(f"Fallback activo para {filename} (images={len(images_b64_fb)}, dpi={fb_dpi})", extra={"pdf": pdf_path, "dpi": fb_dpi})
|
||||
call_t0 = time.time()
|
||||
model_output = self.extractor.extract(images_b64_fb, text_instruction, history=history)
|
||||
call_ms = int((time.time() - call_t0) * 1000)
|
||||
logger.debug(f"Extractor OK tras fallback para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": fb_dpi})
|
||||
|
||||
raw_text = model_output.get("raw", "")
|
||||
usage = model_output.get("usage")
|
||||
json_text = extract_json_from_text(raw_text)
|
||||
if not json_text:
|
||||
logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path})
|
||||
raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.")
|
||||
|
||||
data = json.loads(json_text)
|
||||
# Resumen de claves extraídas (sin volcar todo el JSON)
|
||||
images_b64 = self._convert_to_images(pdf_path, dpi_cfg, thr_cfg, tiff_pages, tiff_page_range)
|
||||
logger.debug(
|
||||
f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg})",
|
||||
extra={"pdf": pdf_path, "dpi": dpi_cfg},
|
||||
)
|
||||
try:
|
||||
if isinstance(data, dict):
|
||||
top_keys = list(data.keys())
|
||||
preview_keys = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
|
||||
logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview_keys})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
else:
|
||||
logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total_ms = int((time.time() - t0) * 1000)
|
||||
logger.info(f"Extracción finalizada exitosamente: {filename} (total_ms={total_ms})", extra={"pdf": pdf_path})
|
||||
model_output = self.extractor.extract(images_b64, text_instruction, history=history)
|
||||
except Exception as e:
|
||||
logger.warning(f"Fallo en extracción. Reintentando con DPI reducido: {filename}", extra={"pdf": pdf_path, "error": str(e)})
|
||||
images_b64_fb = self._apply_extractor_fallback(pdf_path)
|
||||
model_output = self.extractor.extract(images_b64_fb, text_instruction, history=history)
|
||||
|
||||
data, usage = self._parse_model_output(model_output, pdf_path, dpi_cfg)
|
||||
logger.info(f"Extracción finalizada: {filename} (total_ms={int((time.time()-t0)*1000)})", extra={"pdf": pdf_path})
|
||||
return data, usage
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -153,91 +138,38 @@ class InvoiceProcessingService:
|
||||
*,
|
||||
tiff_page_range: Optional[Tuple[int, int]] = None,
|
||||
history: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> Tuple[Any, Any]:
|
||||
"""Versión asíncrona de extract_full_from_pdf."""
|
||||
filename = os.path.basename(pdf_path)
|
||||
logger.info(f"Iniciando extracción asíncrona de documento: {filename}", extra={"pdf": pdf_path})
|
||||
|
||||
t0 = time.time()
|
||||
# Preparar imágenes según configuración
|
||||
dpi_cfg = settings.imaging.pdf_dpi
|
||||
thr_cfg = settings.imaging.pdf_thread_count
|
||||
images_t0 = time.time()
|
||||
tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3))
|
||||
|
||||
try:
|
||||
# Ejecutar conversión de imágenes en thread pool
|
||||
images_b64 = await asyncio.to_thread(
|
||||
document_to_images_bs64,
|
||||
pdf_path,
|
||||
dpi=dpi_cfg,
|
||||
thread_count=thr_cfg,
|
||||
tiff_pages=tiff_pages,
|
||||
tiff_page_range=tiff_page_range,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
|
||||
raise
|
||||
|
||||
images_ms = int((time.time() - images_t0) * 1000)
|
||||
logger.debug(f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg}, ms={images_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
|
||||
# Selección de instrucción por defecto según el contexto
|
||||
profile = get_profile('invoice')
|
||||
text_instruction = instruction if instruction is not None else profile["default_instruction"]
|
||||
inst_len = len(text_instruction) if isinstance(text_instruction, str) else 0
|
||||
hist_len = len(history) if history else 0
|
||||
|
||||
logger.debug(f"Enviando a extractor {filename} (images={len(images_b64)}, prompt_len={inst_len}, history_len={hist_len})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
|
||||
|
||||
try:
|
||||
call_t0 = time.time()
|
||||
model_output = await self.extractor.extract_async(images_b64, text_instruction, history=history)
|
||||
call_ms = int((time.time() - call_t0) * 1000)
|
||||
logger.debug(f"Extractor OK para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
except Exception as e:
|
||||
logger.warning(f"Fallo en extracción por defecto para {filename}. Reintentando con DPI reducido.", extra={"pdf": pdf_path, "error": str(e)})
|
||||
# Fallback: DPI reducido y limitar páginas
|
||||
fb_dpi = 400
|
||||
tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3))
|
||||
|
||||
images_b64_fb = await asyncio.to_thread(
|
||||
document_to_images_bs64,
|
||||
pdf_path,
|
||||
dpi=fb_dpi,
|
||||
tiff_pages=tiff_pages_fb
|
||||
images_b64 = await asyncio.to_thread(
|
||||
self._convert_to_images, pdf_path, dpi_cfg, thr_cfg, tiff_pages, tiff_page_range,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
|
||||
raise
|
||||
|
||||
# Limitar a 5 imágenes en fallback para controlar tamaño
|
||||
if len(images_b64_fb) > 5:
|
||||
images_b64_fb = images_b64_fb[:5]
|
||||
logger.debug(f"Fallback activo para {filename} (images={len(images_b64_fb)}, dpi={fb_dpi})", extra={"pdf": pdf_path, "dpi": fb_dpi})
|
||||
call_t0 = time.time()
|
||||
model_output = await self.extractor.extract_async(images_b64_fb, text_instruction, history=history)
|
||||
call_ms = int((time.time() - call_t0) * 1000)
|
||||
logger.debug(f"Extractor OK tras fallback para {filename} (ms={call_ms})", extra={"pdf": pdf_path, "dpi": fb_dpi})
|
||||
|
||||
raw_text = model_output.get("raw", "")
|
||||
usage = model_output.get("usage")
|
||||
json_text = extract_json_from_text(raw_text)
|
||||
if not json_text:
|
||||
logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path})
|
||||
raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.")
|
||||
|
||||
data = json.loads(json_text)
|
||||
# Resumen de claves extraídas (sin volcar todo el JSON)
|
||||
logger.debug(
|
||||
f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg})",
|
||||
extra={"pdf": pdf_path, "dpi": dpi_cfg},
|
||||
)
|
||||
try:
|
||||
if isinstance(data, dict):
|
||||
top_keys = list(data.keys())
|
||||
preview_keys = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
|
||||
logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview_keys})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
else:
|
||||
logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total_ms = int((time.time() - t0) * 1000)
|
||||
logger.info(f"Extracción finalizada exitosamente: {filename} (total_ms={total_ms})", extra={"pdf": pdf_path})
|
||||
model_output = await self.extractor.extract_async(images_b64, text_instruction, history=history)
|
||||
except Exception as e:
|
||||
logger.warning(f"Fallo en extracción. Reintentando con DPI reducido: {filename}", extra={"pdf": pdf_path, "error": str(e)})
|
||||
images_b64_fb = await asyncio.to_thread(self._apply_extractor_fallback, pdf_path)
|
||||
model_output = await self.extractor.extract_async(images_b64_fb, text_instruction, history=history)
|
||||
|
||||
data, usage = self._parse_model_output(model_output, pdf_path, dpi_cfg)
|
||||
logger.info(f"Extracción finalizada: {filename} (total_ms={int((time.time()-t0)*1000)})", extra={"pdf": pdf_path})
|
||||
return data, usage
|
||||
|
||||
def build_schema_from_first(
|
||||
|
||||
+57
-144
@@ -27,177 +27,90 @@ class InvoiceExtractorStrategy(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
_LOG_EXTRA_BASE = {"dpi": '-', "pdf": '-', "radicado": '-'}
|
||||
|
||||
|
||||
class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
"""Extractor contra un servidor Ollama/OpenAI-compatible.
|
||||
|
||||
Parámetros configurables: `api_key`, `base_url` y `model`.
|
||||
"""
|
||||
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, model: Optional[str] = None, llm_client: Optional[LLMClient] = None) -> None:
|
||||
# Defaults desde settings si no se proveen
|
||||
self.api_key = api_key or settings.api.api_key
|
||||
self.base_url = base_url or settings.api.base_url
|
||||
self.model = model or settings.api.model
|
||||
self.llm = llm_client or LLMClient(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=settings.api.request_timeout_s)
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
def _build_messages(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||
content: List[Dict[str, Any]] = [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img}"}}
|
||||
for img in images_b64
|
||||
]
|
||||
content.append({"type": "text", "text": instruction})
|
||||
messages = list(history) if history else []
|
||||
messages.append({"role": "user", "content": content})
|
||||
return messages
|
||||
|
||||
def _log_call_start(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> None:
|
||||
prompt_len = len(instruction) if isinstance(instruction, str) else 0
|
||||
self._logger.info(
|
||||
"backend_call_start",
|
||||
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s, "images": len(images_b64),
|
||||
"prompt_len": prompt_len, "history_len": len(history) if history else 0},
|
||||
)
|
||||
self._logger.debug(
|
||||
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
|
||||
extra=_LOG_EXTRA_BASE,
|
||||
)
|
||||
|
||||
def _log_call_ok(self, elapsed: float) -> None:
|
||||
self._logger.info(
|
||||
"Extracción completada",
|
||||
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
|
||||
)
|
||||
self._logger.debug(f"Backend OK (elapsed_ms={int(elapsed*1000)})", extra=_LOG_EXTRA_BASE)
|
||||
|
||||
def _log_call_error(self, elapsed: float) -> None:
|
||||
self._logger.exception(
|
||||
"Error en llamada al backend",
|
||||
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
|
||||
)
|
||||
self._logger.debug(f"Backend error tras {int(elapsed*1000)} ms", extra=_LOG_EXTRA_BASE)
|
||||
|
||||
def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
"""Construye el prompt multimodal y devuelve el texto crudo del modelo."""
|
||||
content: List[Dict[str, Any]] = []
|
||||
for image_b64 in images_b64:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
|
||||
})
|
||||
content.append({"type": "text", "text": instruction})
|
||||
|
||||
messages = []
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
||||
messages = self._build_messages(images_b64, instruction, history)
|
||||
self._log_call_start(images_b64, instruction, history)
|
||||
start = time.time()
|
||||
try:
|
||||
# Trazas DEBUG del request (sin exponer imágenes ni texto completo)
|
||||
prompt_len = len(instruction) if isinstance(instruction, str) else 0
|
||||
# Log INFO con metadatos efectivos de la llamada
|
||||
self._logger.info(
|
||||
"backend_call_start",
|
||||
extra={
|
||||
"base_url": self.base_url,
|
||||
"model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s,
|
||||
"images": len(images_b64),
|
||||
"prompt_len": prompt_len,
|
||||
"history_len": len(history) if history else 0,
|
||||
"dpi": '-', "pdf": '-', "radicado": '-',
|
||||
},
|
||||
)
|
||||
self._logger.debug(
|
||||
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
|
||||
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
||||
)
|
||||
text, usage_dict = self.llm.chat_text(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
timeout_s=settings.api.request_timeout_s,
|
||||
component="extractor",
|
||||
messages=messages, model=self.model,
|
||||
timeout_s=settings.api.request_timeout_s, component="extractor",
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
self._logger.info(
|
||||
"Extracción completada",
|
||||
extra={
|
||||
"dpi": '-', "pdf": '-', "radicado": '-',
|
||||
"base_url": self.base_url, "model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s,
|
||||
"elapsed_ms": int(elapsed * 1000),
|
||||
},
|
||||
)
|
||||
self._logger.debug(
|
||||
f"Backend OK (elapsed_ms={int(elapsed*1000)})",
|
||||
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
||||
)
|
||||
return {
|
||||
"raw": text,
|
||||
"usage": usage_dict,
|
||||
}
|
||||
self._log_call_ok(time.time() - start)
|
||||
return {"raw": text, "usage": usage_dict}
|
||||
except Exception:
|
||||
elapsed = time.time() - start
|
||||
self._logger.exception(
|
||||
"Error en llamada al backend",
|
||||
extra={
|
||||
"dpi": '-', "pdf": '-', "radicado": '-',
|
||||
"base_url": self.base_url, "model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s,
|
||||
"elapsed_ms": int(elapsed * 1000),
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._logger.debug(
|
||||
f"Backend error tras {int(elapsed*1000)} ms",
|
||||
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._log_call_error(time.time() - start)
|
||||
raise
|
||||
|
||||
async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
"""Construye el prompt multimodal y devuelve el texto crudo del modelo de forma asíncrona."""
|
||||
content: List[Dict[str, Any]] = []
|
||||
for image_b64 in images_b64:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
|
||||
})
|
||||
content.append({"type": "text", "text": instruction})
|
||||
|
||||
messages = []
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
||||
"""Versión asíncrona de extract."""
|
||||
messages = self._build_messages(images_b64, instruction, history)
|
||||
self._log_call_start(images_b64, instruction, history)
|
||||
start = time.time()
|
||||
try:
|
||||
# Trazas DEBUG del request (sin exponer imágenes ni texto completo)
|
||||
prompt_len = len(instruction) if isinstance(instruction, str) else 0
|
||||
# Log INFO con metadatos efectivos de la llamada
|
||||
self._logger.info(
|
||||
"backend_call_start",
|
||||
extra={
|
||||
"base_url": self.base_url,
|
||||
"model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s,
|
||||
"images": len(images_b64),
|
||||
"prompt_len": prompt_len,
|
||||
"history_len": len(history) if history else 0,
|
||||
"dpi": '-', "pdf": '-', "radicado": '-',
|
||||
},
|
||||
)
|
||||
self._logger.debug(
|
||||
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
|
||||
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
||||
)
|
||||
text, usage_dict = await self.llm.chat_text_async(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
timeout_s=settings.api.request_timeout_s,
|
||||
component="extractor",
|
||||
messages=messages, model=self.model,
|
||||
timeout_s=settings.api.request_timeout_s, component="extractor",
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
self._logger.info(
|
||||
"Extracción completada",
|
||||
extra={
|
||||
"dpi": '-', "pdf": '-', "radicado": '-',
|
||||
"base_url": self.base_url, "model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s,
|
||||
"elapsed_ms": int(elapsed * 1000),
|
||||
},
|
||||
)
|
||||
self._logger.debug(
|
||||
f"Backend OK (elapsed_ms={int(elapsed*1000)})",
|
||||
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
||||
)
|
||||
return {
|
||||
"raw": text,
|
||||
"usage": usage_dict,
|
||||
}
|
||||
self._log_call_ok(time.time() - start)
|
||||
return {"raw": text, "usage": usage_dict}
|
||||
except Exception:
|
||||
elapsed = time.time() - start
|
||||
self._logger.exception(
|
||||
"Error en llamada al backend",
|
||||
extra={
|
||||
"dpi": '-', "pdf": '-', "radicado": '-',
|
||||
"base_url": self.base_url, "model": self.model,
|
||||
"timeout_s": settings.api.request_timeout_s,
|
||||
"elapsed_ms": int(elapsed * 1000),
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._logger.debug(
|
||||
f"Backend error tras {int(elapsed*1000)} ms",
|
||||
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._log_call_error(time.time() - start)
|
||||
raise
|
||||
|
||||
def update_config(self, *, api_key: str | None = None, base_url: str | None = None, model: str | None = None) -> None:
|
||||
|
||||
+23
-19
@@ -15,19 +15,28 @@ from PIL import Image, ImageSequence
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
from application.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_POPPLER = 'dependencies/poppler-24.08.0/Library/bin'
|
||||
|
||||
def pdf_to_images_bs64(pdf_path: str, validate: bool = True, dpi: int = 800, thread_count: int = 10) -> List[str]:
|
||||
|
||||
def _resolve_poppler(poppler_path: str | None) -> str:
|
||||
"""Devuelve la ruta a Poppler: usa la provista o el default del proyecto."""
|
||||
return poppler_path or _DEFAULT_POPPLER
|
||||
|
||||
|
||||
def pdf_to_images_bs64(
|
||||
pdf_path: str,
|
||||
validate: bool = True,
|
||||
dpi: int = 800,
|
||||
thread_count: int = 10,
|
||||
poppler_path: str | None = None,
|
||||
) -> List[str]:
|
||||
"""Convierte un PDF a una lista de PNGs en base64.
|
||||
|
||||
Usa pdf2image + Poppler. Lanza ValueError si una imagen no valida.
|
||||
"""
|
||||
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
|
||||
poppler_bin = _resolve_poppler(poppler_path)
|
||||
t0 = time.time()
|
||||
logger.info("Convirtiendo PDF a imágenes", extra={"pdf": pdf_path, "dpi": dpi})
|
||||
images = convert_from_path(pdf_path, dpi=dpi, thread_count=thread_count, poppler_path=poppler_bin)
|
||||
@@ -50,10 +59,9 @@ def pdf_to_images_bs64(pdf_path: str, validate: bool = True, dpi: int = 800, thr
|
||||
return resultados
|
||||
|
||||
|
||||
def get_pdf_num_pages(pdf_path: str) -> int:
|
||||
def get_pdf_num_pages(pdf_path: str, poppler_path: str | None = None) -> int:
|
||||
"""Devuelve el número de páginas de un PDF usando pdfinfo_from_path."""
|
||||
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
|
||||
info = pdfinfo_from_path(pdf_path, poppler_path=poppler_bin)
|
||||
info = pdfinfo_from_path(pdf_path, poppler_path=_resolve_poppler(poppler_path))
|
||||
try:
|
||||
return int(info.get('Pages') or info.get('pages') or 0)
|
||||
except Exception:
|
||||
@@ -69,12 +77,13 @@ def pdf_pages_to_images_bs64(
|
||||
grayscale: bool = True,
|
||||
validate: bool = False,
|
||||
thread_count: int = 10,
|
||||
poppler_path: str | None = None,
|
||||
) -> List[str]:
|
||||
"""Convierte un rango [first_page, last_page] (1-indexed, inclusivo) a PNGs base64.
|
||||
|
||||
Opcionalmente aplica escala de grises y omite validación para acelerar.
|
||||
"""
|
||||
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
|
||||
poppler_bin = _resolve_poppler(poppler_path)
|
||||
t0 = time.time()
|
||||
logger.debug(
|
||||
f"Convirtiendo rango PDF→imgs (first={first_page}, last={last_page}, dpi={dpi}, gray={grayscale})",
|
||||
@@ -155,26 +164,21 @@ def document_to_images_bs64(
|
||||
thread_count: int = 10,
|
||||
tiff_pages: Optional[Tuple[int, int]] = (2, 3),
|
||||
tiff_page_range: Optional[Tuple[int, int]] = None,
|
||||
poppler_path: str | None = None,
|
||||
) -> List[str]:
|
||||
"""Puerta de entrada: decide conversión según la extensión (.pdf, .tif/.tiff).
|
||||
|
||||
`tiff_page_range` permite indicar un rango explícito (inicio, fin) 1-based
|
||||
para TIFF. Si es None, se usa `tiff_pages` como valor por defecto.
|
||||
`poppler_path` se pasa a las funciones PDF; si es None se usa el default del proyecto.
|
||||
"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".pdf":
|
||||
return pdf_to_images_bs64(path, validate=validate, dpi=dpi, thread_count=thread_count)
|
||||
return pdf_to_images_bs64(path, validate=validate, dpi=dpi, thread_count=thread_count, poppler_path=poppler_path)
|
||||
if ext in (".tif", ".tiff"):
|
||||
# Prioridad:
|
||||
# - (None, None) => procesar todas las páginas del TIFF (sin recorte).
|
||||
# - rango explícito (inicio, fin) => usar ese rango.
|
||||
# - None => usar el valor por defecto de configuración (`tiff_pages`).
|
||||
effective_range = tiff_pages
|
||||
if tiff_page_range is not None:
|
||||
if tiff_page_range == (None, None):
|
||||
effective_range = None
|
||||
else:
|
||||
effective_range = tiff_page_range
|
||||
effective_range = None if tiff_page_range == (None, None) else tiff_page_range
|
||||
return tiff_to_images_bs64(path, validate=validate, pages_range=effective_range)
|
||||
raise ValueError(f"Formato no soportado: {ext}")
|
||||
|
||||
|
||||
-270
@@ -1,270 +0,0 @@
|
||||
# IPSExtractor — Documentación técnica (Desarrolladores)
|
||||
|
||||
Este documento explica la arquitectura, módulos, flujos, modelo de datos/CSV, estados de la GUI, configuración y extensibilidad del proyecto. Contiene diagramas Mermaid incrustados.
|
||||
|
||||
## Índice
|
||||
|
||||
- [Visión general](#visión-general)
|
||||
- [Arquitectura de componentes](#arquitectura-de-componentes)
|
||||
- [Módulos y responsabilidades](#módulos-y-responsabilidades)
|
||||
- [Flujos de ejecución](#flujos-de-ejecución)
|
||||
- [Modelo de datos y CSV](#modelo-de-datos-y-csv)
|
||||
- [Estados de la GUI y manejo de errores](#estados-de-la-gui-y-manejo-de-errores)
|
||||
- [Configuración y ejecución](#configuración-y-ejecución)
|
||||
- [Extensibilidad](#extensibilidad)
|
||||
- [Registro y métricas](#registro-y-métricas)
|
||||
|
||||
## Visión general
|
||||
|
||||
La aplicación procesa carpetas con “radicados” que contienen PDFs/TIFFs de facturas. Orquesta la conversión de documentos a imágenes Base64, llama a una estrategia de extracción para obtener un JSON, normaliza el resultado a un esquema seleccionado por el usuario y exporta CSV incremental. Incluye GUI con selección interactiva de campos y estimación de costos por tokens.
|
||||
|
||||
## Arquitectura de componentes
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph GUI
|
||||
V[gui/view.py]
|
||||
C[gui/controller.py]
|
||||
FS[gui/field_selector.py]
|
||||
end
|
||||
|
||||
subgraph APP[application/*]
|
||||
S[service.py]
|
||||
STG[strategies.py<br/>(InvoiceExtractorStrategy)]
|
||||
NRM[normalizer.py]
|
||||
PR[profiles.py]
|
||||
PB[prompts.py]
|
||||
CFG[config.py]
|
||||
end
|
||||
|
||||
subgraph CORE
|
||||
PDF[core/pdf_utils.py]
|
||||
TXT[core/text_utils.py]
|
||||
CSV[core/csv_utils.py]
|
||||
UT[core/utils.py]
|
||||
end
|
||||
|
||||
V --> C
|
||||
C --> S
|
||||
C --> FS
|
||||
S --> STG
|
||||
S --> NRM
|
||||
S --> PR
|
||||
S --> PB
|
||||
S --> CFG
|
||||
S --> PDF
|
||||
S --> TXT
|
||||
S --> CSV
|
||||
S --> UT
|
||||
|
||||
S --> LOG[(logs/*)]
|
||||
```
|
||||
|
||||
## Módulos y responsabilidades
|
||||
|
||||
- `application/service.py`: orquestación principal (extracción con fallback, schema inicial, normalización, CSV incremental, estimación de costos/tokens).
|
||||
- Funciones clave:
|
||||
- `extract_full_from_pdf(pdf_path, instruction?)`: conversión a imágenes, llamada a extractor, parseo JSON, fallback DPI/páginas.
|
||||
- `build_schema_from_first(pdf_path, instruction?)`: genera `schema` a partir del primer documento.
|
||||
- `build_schema_from_first_for_types(root_path, doc_types)`: genera `schema_by_type` usando el primer radicado y la primera coincidencia por keyword de cada tipo.
|
||||
- `process_root_with_field_selection(root_path, keywords|doc_types, fields?, ...)`: flujo por radicado; soporta multi-documento; selección de campos; normalización y CSV incremental.
|
||||
- `estimate_tokens_and_cost(root_path, keywords|doc_types, ...)`: simulación con primer radicado; en multi suma por tipo.
|
||||
- `application/profiles.py`: perfiles por tipo de documento e instrucciones por defecto.
|
||||
- `application/strategies.py`: interfaz/estrategia de extracción (`InvoiceExtractorStrategy`).
|
||||
- `application/normalizer.py`: `normalize_to_schema(...)` para ajustar el JSON al esquema elegido.
|
||||
- `application/prompts.py`: `PromptBuilder.build(...)` para prompts reducidos según campos seleccionados.
|
||||
- `application/config.py`: `settings` (imaging, hilos, DPI, etc.).
|
||||
- `core/pdf_utils.py`: conversión PDF/TIFF → imágenes Base64.
|
||||
- `core/text_utils.py`: `extract_json_from_text`, `build_schema`.
|
||||
- `core/csv_utils.py`: aplanado de campos no-lista, detección de la primera lista de ítems, agregaciones y encabezados.
|
||||
- `core/utils.py`: utilidades (p. ej., `collect_radicado_docs`).
|
||||
- `gui/controller.py`: coordina GUI ↔ servicio, selector de campos y callbacks de progreso/uso.
|
||||
- `gui/field_selector.py`: diálogo de selección de paths y alias (valida duplicados y permite reproceso). En modo multi, agrupa visualmente por `Tipo` (p. ej., `Factura/...`, `Remision/...`).
|
||||
- `gui/view.py`: añade lista dinámica de Tipos de Documento (Nombre Lógico + Keyword).
|
||||
- `gui/view.py`: componentes visuales y actualizaciones (KPIs, barras, líneas de costo, etc.).
|
||||
|
||||
## Flujos de ejecución
|
||||
|
||||
Incluye: disparo desde GUI, descubrimiento de radicados, creación de schema, selección de campos/alias, estimación y confirmación, extracción por documento con fallback, normalización y exportación incremental a CSV.
|
||||
|
||||
### Secuencia general (GUI → Service → Normalización → CSV)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as Usuario
|
||||
participant V as gui/view.py
|
||||
participant C as gui/controller.py
|
||||
participant S as application/service.py
|
||||
participant FS as gui/field_selector.py
|
||||
participant ST as application/strategies.py
|
||||
participant N as application/normalizer.py
|
||||
participant CSV as core/csv_utils.py
|
||||
participant TXT as core/text_utils.py
|
||||
participant UT as core/utils.py
|
||||
|
||||
U->>V: Click "Procesar"
|
||||
V->>C: on_process()
|
||||
note over C: Lee root, keywords, fields opcionales
|
||||
C->>S: process_root_with_field_selection(fields=None, doc_types?)
|
||||
S->>UT: collect_radicado_docs()
|
||||
S->>S: build_schema_from_first(first.pdf)
|
||||
S->>TXT: extract_json_from_text(raw)
|
||||
S-->>C: [{ _needs_fields: true, schema, first_full }]
|
||||
C->>FS: Abrir diálogo de selección (schema + sample)
|
||||
FS-->>C: on_submit(selected_paths, alias_map) Note: paths con prefijo de tipo `Tipo/Subruta`
|
||||
C->>S: estimate_tokens_and_cost(..., doc_types?, schema_by_type?, selected_paths?)
|
||||
C->>U: Confirmación (costos estimados)
|
||||
U-->>C: Aceptar
|
||||
C->>S: process_root_with_field_selection(fields=selected_fields, alias_map)
|
||||
loop Por cada radicado
|
||||
alt Multi-documento
|
||||
loop Por cada Tipo
|
||||
S->>ST: extract(images, prompt reducido por Tipo)
|
||||
S->>N: normalize_to_schema(data, paths del Tipo)
|
||||
end
|
||||
S-->>S: fusiona por Tipo => fused_data = {'radicado': R, 'Factura': {...}, 'Remision': {...}}
|
||||
S->>CSV: rows_from_data(fused_data, allowed_paths con prefijo)
|
||||
else Single-documento
|
||||
S->>ST: extract(images, prompt reducido)
|
||||
S->>N: normalize_to_schema(data, paths)
|
||||
S->>CSV: rows_from_data(data, allowed_paths, aggregate_list_paths?)
|
||||
end
|
||||
S->>S: _append_rows_to_csv(...)
|
||||
S-->>C: on_usage(phase, radicado, usage, cost)
|
||||
C->>V: Actualiza KPIs/barras/líneas
|
||||
end
|
||||
S-->>C: resultados finales (JSON)
|
||||
C->>V: Imprime resultados en Output
|
||||
```
|
||||
|
||||
### Secuencia de extracción con fallback
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant S as application/service.py
|
||||
participant PDF as core/pdf_utils.py
|
||||
participant ST as application/strategies.py
|
||||
participant TXT as core/text_utils.py
|
||||
|
||||
S->>PDF: document_to_images_bs64(pdf, dpi=config, threads)
|
||||
S->>ST: extract(images_b64, instruction)
|
||||
alt Éxito
|
||||
ST-->>S: {raw, usage}
|
||||
else Error/Timeout/Payload
|
||||
S->>PDF: document_to_images_bs64(pdf, dpi=fallback, pages<=5)
|
||||
S->>ST: extract(images_b64_fb, instruction)
|
||||
ST-->>S: {raw, usage}
|
||||
end
|
||||
S->>TXT: extract_json_from_text(raw)
|
||||
TXT-->>S: json_text
|
||||
S-->>S: json.loads(json_text) => data
|
||||
```
|
||||
|
||||
## Modelo de datos y CSV
|
||||
|
||||
Describe cómo se aplanan campos no-lista, cómo se detecta la primera lista de ítems, cómo se agregan listas a columnas y cómo `allowed_paths` y los alias determinan el encabezado y las filas del CSV.
|
||||
|
||||
### Diagrama de datos
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class ExtractedData {
|
||||
+string radicado
|
||||
+dict ...campos_no_lista
|
||||
+Item[] items
|
||||
+string _source_pdf
|
||||
}
|
||||
class Item {
|
||||
+string Codigo
|
||||
+int Cantidad
|
||||
+float V/Unitario
|
||||
+... otros subcampos anidados
|
||||
}
|
||||
ExtractedData "1" --> "0..*" Item : items
|
||||
```
|
||||
|
||||
Notas:
|
||||
- Los “campos no lista” se aplanan con separador `/`, por ejemplo: `Paciente/Nombre`, `Factura/Fecha`.
|
||||
- La primera lista de objetos detectada se considera “lista de ítems principal” y se expande en columnas.
|
||||
|
||||
### Mapeo a CSV (`core/csv_utils.py`)
|
||||
|
||||
- Aplanado no-lista: `flatten_non_list_fields(data)` produce columnas para todas las hojas escalar (excluye listas).
|
||||
- Detección de lista principal: `_find_first_items_list(data)` devuelve `items_key` y `items`.
|
||||
- Subcolumnas de ítems: se aplanan como `items_key/*/subruta`, por ejemplo: `items/*/V/Unitario`.
|
||||
- Filtrado por `allowed_paths` (selección del usuario): solo se mantienen rutas de esa lista (más `radicado` siempre).
|
||||
- Agregación opcional: `aggregate_list_paths=[base_path]` genera columnas `base_path/*/subclave` con valores unidos por coma (para listas no principales).
|
||||
- Encabezado final: `['radicado', ...campos_no_lista, ...campos_items]` respetando `allowed_paths` y orden alfabético de subrutas.
|
||||
- Alias: el `alias_map` de la GUI renombra columnas en el CSV final (encabezado y filas).
|
||||
|
||||
Ejemplos de columnas resultantes:
|
||||
- `radicado`
|
||||
- `Factura/Número`
|
||||
- `Paciente/Nombre`
|
||||
- `items/*/Codigo`
|
||||
- `items/*/V/Unitario`
|
||||
|
||||
## Estados de la GUI y manejo de errores
|
||||
|
||||
Estados principales: Idle → Processing → (NeedsFields) → Estimating → Confirming → Processing2 → Done/Error. La extracción tiene fallback controlado y los errores se registran y opcionalmente se reflejan como filas de error en el CSV.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Processing: Click "Procesar"
|
||||
Processing --> NeedsFields: Service devuelve _needs_fields
|
||||
NeedsFields --> Estimating: on_submit(selected_fields, alias_map)
|
||||
Estimating --> Confirming: Mostrar costos estimados
|
||||
Confirming --> Processing2: Usuario acepta
|
||||
Confirming --> Idle: Usuario cancela
|
||||
Processing2 --> Done: Todos los radicados OK
|
||||
Processing2 --> Error: Excepción por radicado
|
||||
Done --> Idle
|
||||
Error --> Idle
|
||||
```
|
||||
|
||||
Manejo de errores/fallback:
|
||||
- Extracción: si falla la primera llamada (timeout/payload), se reprocesa con DPI reducido y límite de páginas (≤5).
|
||||
- Escritura CSV: filas de error con `radicado`, `_source_pdf`, `error` cuando hay excepción.
|
||||
- Logs: se emiten `logger.info/debug/exception` con contexto (`pdf`, `radicado`, métricas de tiempo y tokens).
|
||||
- GUI: mantiene KPIs y barreras por fase; errores no bloquean el procesamiento de los siguientes radicados.
|
||||
|
||||
## Configuración y ejecución
|
||||
|
||||
Parámetros clave en `application/config.py`:
|
||||
- `settings.imaging.pdf_dpi`: DPI para rasterizar PDF/TIFF (impacta calidad/tamaño).
|
||||
- `settings.imaging.pdf_thread_count`: paralelismo al generar imágenes.
|
||||
- `settings.imaging.tiff_pages`: páginas a considerar por defecto en TIFF multi-página.
|
||||
|
||||
Ejecución (GUI):
|
||||
1) Inicie la aplicación GUI.
|
||||
2) Seleccione la carpeta raíz de radicados (cada radicado contiene PDFs/TIFFs).
|
||||
3) Opcional: defina palabras clave de búsqueda (por defecto: `Factura_O_Documento_Equivalente, factura`).
|
||||
4) Opcional: defina campos a extraer (si se deja vacío se abrirá el selector).
|
||||
5) Opcional: elija ruta de salida para el CSV.
|
||||
6) Presione “Procesar”.
|
||||
|
||||
Exportación CSV:
|
||||
- Si se especifica `csv_output_path`, el servicio añadirá filas incrementalmente conforme procesa cada documento.
|
||||
- Los encabezados se generan en la primera escritura; si existe un CSV previo con contenido, se preserva su encabezado.
|
||||
|
||||
## Extensibilidad
|
||||
|
||||
Nuevos perfiles, estrategias de extracción o ajustes en normalización y costos se pueden incorporar con cambios locales y mínimos acoplamientos.
|
||||
|
||||
- Perfiles (`application/profiles.py`): agregue una entrada en `PROFILES` definiendo `default_instruction`, `aggregate_list_paths`, etc.
|
||||
- Estrategias de extracción (`application/strategies.py`): implemente `InvoiceExtractorStrategy.extract(images_b64, instruction)` para soportar nuevos backends/LLMs.
|
||||
- Normalización (`application/normalizer.py`): adapte `normalize_to_schema` para modelos y políticas de mapeo; los costos por fase se calculan en el servicio.
|
||||
- Prompts (`application/prompts.py`): `PromptBuilder.build(selected_fields, schema)` puede ajustarse para controlar la precisión y tamaño del prompt.
|
||||
- Estimación de costos (`application/service.py::estimate_tokens_and_cost`): modifique tarifas/modelos según su despliegue.
|
||||
|
||||
Preferencia de diseño:
|
||||
- Siempre que sea posible, se recomienda ajustar el prompt para conseguir un JSON estable en vez de depender de mapeos/alias complejos en código [[memory:10659104]].
|
||||
|
||||
## Registro y métricas
|
||||
|
||||
La app registra progreso, tiempos y costos por fase y por documento. Los logs se escriben en `logs/*` y la GUI muestra KPIs agregados.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
|
||||
@@ -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}"),
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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")
|
||||
```
|
||||
|
||||
|
||||
@@ -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")
|
||||
```
|
||||
|
||||
|
||||
@@ -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"})
|
||||
```
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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"]
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
|
||||
@@ -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))
|
||||
```
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# `gui/view.py`
|
||||
|
||||
## Propósito
|
||||
Vista principal basada en `customtkinter` que define la interfaz de procesamiento y expone métodos para enlazar callbacks y comunicar estado.
|
||||
|
||||
## API pública
|
||||
|
||||
### `class InvoiceAppView`
|
||||
- `set_on_select_root(cb)`, `set_on_process(cb)`, `set_on_select_csv(cb)`
|
||||
- Getters: `get_root_path()`, `get_keywords()`, `get_fields()`, `get_csv_path()`
|
||||
- Output: `append_output(text)`, `append_output_safe(text)`
|
||||
- Estado: `set_processing(bool)`
|
||||
|
||||
### Keywords / patrones de archivos
|
||||
|
||||
- En la pestaña principal, cada Tipo de Documento tiene un campo de **Keyword/Patrón**.
|
||||
- Se aceptan tanto palabras simples (sin `*` ni `?`) como patrones tipo Windows con `*` y `?`.
|
||||
- Si el patrón **no incluye extensión**, se aplica sobre el nombre sin extensión y solo se consideran archivos `.pdf`, `.tif`, `.tiff`.
|
||||
- Si el patrón **incluye extensión** válida (p. ej. `*_OK?.pdf`), se aplica sobre el nombre completo respetando esa extensión.
|
||||
|
||||
## Ejemplo
|
||||
```python
|
||||
from gui.view import InvoiceAppView
|
||||
|
||||
view = InvoiceAppView()
|
||||
view.set_on_process(lambda: print("Procesar!"))
|
||||
view.mainloop()
|
||||
```
|
||||
|
||||
|
||||
+6
-25
@@ -18,7 +18,7 @@ from application.preclassification import PreclassificationService
|
||||
from application.strategies import OllamaExtractor
|
||||
from application.database import DatabaseManager
|
||||
from application.config import settings
|
||||
from application.costs import compute_cost_from_usage, MODEL_PRICING, sum_costs
|
||||
from application.costs import MODEL_PRICING
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@@ -168,8 +168,7 @@ class AppController:
|
||||
selected = fd.askdirectory()
|
||||
if not selected:
|
||||
return
|
||||
self.view.root_entry.delete(0, 'end')
|
||||
self.view.root_entry.insert(0, selected)
|
||||
self.view.set_root_path(selected)
|
||||
|
||||
# Detectar si el primer documento a procesar es TIFF para esta carpeta
|
||||
try:
|
||||
@@ -195,26 +194,15 @@ class AppController:
|
||||
selected = fd.askdirectory()
|
||||
if not selected:
|
||||
return
|
||||
try:
|
||||
self.view.preclass_root_entry.delete(0, 'end')
|
||||
self.view.preclass_root_entry.insert(0, selected)
|
||||
except Exception:
|
||||
# fallback si cambia el nombre del widget
|
||||
try:
|
||||
setter = getattr(self.view, "set_preclass_root_path", None)
|
||||
if callable(setter):
|
||||
setter(selected)
|
||||
except Exception:
|
||||
pass
|
||||
self.view.set_preclass_root_path(selected)
|
||||
|
||||
def on_run_preclassification(self):
|
||||
"""Ejecuta la preclasificación en un hilo separado."""
|
||||
# 1. Leer inputs de la UI
|
||||
root = self.view.get_preclass_root_path()
|
||||
mode = getattr(self.view, "get_preclass_mode", lambda: "radicados")()
|
||||
patterns = self.view.get_preclass_patterns()
|
||||
target_info = self.view.get_preclass_target_info()
|
||||
page_range = self.view.get_preclass_range() # (enabled, start, end)
|
||||
page_range = self.view.get_preclass_range()
|
||||
mode = self.view.get_preclass_mode()
|
||||
|
||||
# Validaciones básicas
|
||||
@@ -359,15 +347,9 @@ class AppController:
|
||||
# --- Pestaña Resultados ---
|
||||
def on_res_select(self, event):
|
||||
"""Muestra detalles del resultado seleccionado."""
|
||||
selection = self.view.tree_results.selection()
|
||||
if not selection:
|
||||
return
|
||||
|
||||
# values: (ID, Run, Fecha, Radicado, Estado, Archivo)
|
||||
item_vals = self.view.tree_results.item(selection[0], "values")
|
||||
item_vals = self.view.get_selected_result_values()
|
||||
if not item_vals:
|
||||
return
|
||||
|
||||
try:
|
||||
res_id = int(item_vals[0])
|
||||
matches = self.db_manager.get_matches_for_result(res_id)
|
||||
@@ -464,8 +446,7 @@ class AppController:
|
||||
"""Abre un diálogo de guardado y establece la ruta del CSV de salida."""
|
||||
selected = fd.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV", "*.csv"), ("Todos", "*.*")])
|
||||
if selected:
|
||||
self.view.csv_entry.delete(0, 'end')
|
||||
self.view.csv_entry.insert(0, selected)
|
||||
self.view.set_csv_path(selected)
|
||||
|
||||
def on_process(self):
|
||||
"""Inicia el flujo de procesamiento por radicado (solo facturas) en un hilo de fondo.
|
||||
|
||||
+15
-1
@@ -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'."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -202,6 +202,13 @@ class ResultsTab:
|
||||
def set_on_select_result(self, callback):
|
||||
"""Vincula el callback al evento de selección de fila."""
|
||||
self.tree_results.bind("<<TreeviewSelect>>", callback)
|
||||
|
||||
def get_selected_result_values(self) -> tuple | None:
|
||||
"""Devuelve los valores de la fila seleccionada o None si no hay selección."""
|
||||
selection = self.tree_results.selection()
|
||||
if not selection:
|
||||
return None
|
||||
return self.tree_results.item(selection[0], "values") or None
|
||||
|
||||
def update_matches_table(self, rows):
|
||||
"""Actualiza la tabla de matches con los datos proporcionados."""
|
||||
|
||||
+13
-13
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user