chore: Remove obsolete build and GUI files, and introduce a new Docker Compose setup for microservices architecture. This change streamlines the project structure by eliminating legacy scripts and integrating a PostgreSQL database service alongside extraction and cleaning services.
This commit is contained in:
@@ -1,117 +1,221 @@
|
||||
## IPSExtractor
|
||||
## IPSExtractor Microservices
|
||||
|
||||
Sistema avanzado para la extracción y procesamiento de datos de facturas médicas (IPS) utilizando modelos de Inteligencia Artificial Multimodal (LLMs). Convierte documentos (PDF/TIFF) a imágenes, extrae información estructurada, la normaliza y la exporta a CSV.
|
||||
Monorepo con servicios separados como si fueran repos independientes. Cada
|
||||
servicio tiene su propia API, dependencias, Dockerfile, codigo de dominio e
|
||||
infraestructura local. No hay paquetes compartidos entre servicios.
|
||||
|
||||
### 🚀 Características Principales
|
||||
```text
|
||||
services/
|
||||
extraction/ Repo/servicio de extraccion
|
||||
api/ API FastAPI
|
||||
domain/ Dominio propio de extraccion y normalizacion
|
||||
core/ Utilidades propias para PDF, texto y filas
|
||||
infrastructure/ Persistencia propia del servicio
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
|
||||
- **Extracción Multimodal**: Usa modelos de visión (como `qwen2.5vl`) vía API compatible con OpenAI/Ollama para "leer" documentos como imágenes.
|
||||
- **Procesamiento Inteligente**:
|
||||
- Conversión automática de PDF/TIFF a imágenes optimizadas.
|
||||
- Descubrimiento automático de documentos por radicado (prioriza TIFF).
|
||||
- **Normalización de Datos**: Estandarización de salidas JSON mediante post-procesamiento con LLM.
|
||||
- **Gestión Robusta**:
|
||||
- Sistema de **Logs Estructurados** (JSON) y salida en consola coloreada para fácil depuración.
|
||||
- Estimación de costos y tokens antes de ejecutar.
|
||||
- Manejo de reintentos y "fallback" automático (reducción de DPI) en caso de fallos.
|
||||
- **Interfaz Gráfica (GUI)**:
|
||||
- Selección interactiva de carpetas y configuración.
|
||||
- Selector visual de campos a extraer (Schema Builder).
|
||||
- Barra de progreso y logs en tiempo real.
|
||||
- **Exportación**: Generación incremental de reportes CSV (una fila por ítem de factura).
|
||||
cleaning/ Repo/servicio de limpieza
|
||||
api/ API FastAPI
|
||||
domain/ Reglas propias de limpieza
|
||||
infrastructure/ Persistencia propia del servicio
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
|
||||
### 📋 Requisitos
|
||||
api_gateway/ API central opcional
|
||||
api/ Proxy HTTP hacia extraction y cleaning
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
```
|
||||
|
||||
- **Python 3.10+**
|
||||
- **Poppler** (para procesar PDFs):
|
||||
- Windows: Incluido en `dependencies/poppler-24.08.0/Library/bin`.
|
||||
- Linux/Mac: Instalar vía gestor de paquetes (`sudo apt install poppler-utils`).
|
||||
- **Acceso a LLM**: Un servidor Ollama corriendo localmente o acceso a una API compatible con OpenAI.
|
||||
La base de datos Postgres es compartida como contrato de integracion, pero el
|
||||
codigo de acceso a datos esta duplicado dentro de cada servicio para que puedan
|
||||
vivir por separado.
|
||||
|
||||
### 🛠️ Instalación
|
||||
### Puertos
|
||||
|
||||
1. **Clonar el repositorio y crear entorno virtual**:
|
||||
```bash
|
||||
python -m venv .venv
|
||||
# Windows
|
||||
.venv\Scripts\activate
|
||||
# Linux/Mac
|
||||
source .venv/bin/activate
|
||||
```
|
||||
| Servicio | Puerto | Uso |
|
||||
| --- | ---: | --- |
|
||||
| `api_gateway` | `8000` | API central opcional |
|
||||
| `extraction` | `8001` | API propia de extraccion |
|
||||
| `cleaning` | `8002` | API propia de limpieza |
|
||||
| `database` | `5432` | Postgres |
|
||||
|
||||
2. **Instalar dependencias**:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
Documentacion interactiva:
|
||||
|
||||
### ⚙️ Configuración (.env)
|
||||
- `http://localhost:8000/docs`
|
||||
- `http://localhost:8001/docs`
|
||||
- `http://localhost:8002/docs`
|
||||
|
||||
La configuración se gestiona mediante variables de entorno. Crea un archivo `.env` en la raíz con los siguientes parámetros (ajusta según tu entorno):
|
||||
### Variables
|
||||
|
||||
```env
|
||||
# Configuración del Modelo Extractor
|
||||
EXTRACTOR_BASE_URL=http://10.8.0.4:11434/v1
|
||||
EXTRACTOR_API_KEY=ollama
|
||||
EXTRACTOR_MODEL=qwen2.5vl:32b
|
||||
IPS_DOCUMENT_ROOT=C:/ruta/a/documentos
|
||||
|
||||
# Ejemplo: extraccion con Grok/xAI
|
||||
EXTRACTOR_PROVIDER=xai
|
||||
EXTRACTOR_BASE_URL=https://api.x.ai/v1
|
||||
EXTRACTOR_API_KEY=tu_xai_api_key
|
||||
EXTRACTOR_MODEL=grok-tu-modelo
|
||||
EXTRACTOR_TIMEOUT_S=120
|
||||
|
||||
# Configuración de Procesamiento de Imagen
|
||||
# Ejemplo: normalizacion con OpenAI
|
||||
NORMALIZER_PROVIDER=openai
|
||||
NORMALIZER_BASE_URL=https://api.openai.com/v1
|
||||
NORMALIZER_API_KEY=tu_openai_api_key
|
||||
NORMALIZER_MODEL=gpt-tu-modelo
|
||||
NORMALIZER_TIMEOUT_S=120
|
||||
|
||||
PDF_DPI=600
|
||||
PDF_THREAD_COUNT=10
|
||||
TIFF_PAGES=(2,3)
|
||||
|
||||
# Configuración de Rutas y Salida
|
||||
POPPLER_PATH=dependencies/poppler-24.08.0/Library/bin
|
||||
CSV_DELIMITER=|
|
||||
CONCURRENCY_LIMIT=4
|
||||
ALLOWED_DOCUMENT_TYPES=CC,TI,CE,RC,PA,PPT,PEP,EPT,NUIP
|
||||
```
|
||||
|
||||
### 🖥️ Uso de la Aplicación
|
||||
Si `NORMALIZER_BASE_URL` o `NORMALIZER_API_KEY` se dejan vacios, el servicio usa
|
||||
el mismo backend configurado en `EXTRACTOR_BASE_URL` y `EXTRACTOR_API_KEY`.
|
||||
|
||||
Ejecuta el punto de entrada principal:
|
||||
Dentro de Docker, `IPS_DOCUMENT_ROOT` se monta como `/data`.
|
||||
|
||||
### Levantar
|
||||
|
||||
```bash
|
||||
python gui_main.py
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
#### Flujo de Trabajo en GUI:
|
||||
1. **Pestaña "Procesar"**:
|
||||
* Selecciona la carpeta raíz que contiene las subcarpetas de los radicados.
|
||||
* (Opcional) Define keywords para filtrar archivos específicos.
|
||||
* Selecciona dónde guardar el CSV de salida.
|
||||
* Haz clic en **"Procesar"**.
|
||||
* *Si es la primera vez*: Se abrirá un selector de campos para que elijas qué datos extraer basándose en el primer documento detectado.
|
||||
2. **Pestaña "Configuración"**:
|
||||
* Permite ajustar dinámicamente la URL de la API o el modelo sin reiniciar la aplicación.
|
||||
Health checks:
|
||||
|
||||
### 🏗️ Arquitectura del Código
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8001/health
|
||||
curl http://localhost:8002/health
|
||||
```
|
||||
|
||||
El proyecto sigue una arquitectura modular para facilitar el mantenimiento y la escalabilidad:
|
||||
### Flujo Directo Por Servicio
|
||||
|
||||
**Capa de Aplicación (`application/`):**
|
||||
- `service.py`: **Fachada** principal (`InvoiceProcessingService`). Punto de entrada único para la lógica de negocio.
|
||||
- `batch_processor.py`: Motor de procesamiento masivo. Maneja la iteración de radicados, reportes de progreso y orquestación multi-hilo.
|
||||
- `estimator.py`: Lógica para calcular costos estimados y uso de tokens antes de lanzar procesos largos.
|
||||
- `strategies.py`: Implementación del cliente LLM (`OllamaExtractor`) y patrones de extracción.
|
||||
- `normalizer.py`: Servicio de normalización de datos JSON post-extracción para asegurar consistencia en el esquema.
|
||||
- `csv_manager.py`: Manejador thread-safe para la escritura incremental de archivos CSV.
|
||||
- `logging_config.py`: Configuración avanzada de logging (salida JSON para máquinas y colores para humanos).
|
||||
- `file_discovery.py`: Utilidades para exploración eficiente del sistema de archivos.
|
||||
1. Ver modelos disponibles en extraccion:
|
||||
|
||||
**Capa Core (`core/`):**
|
||||
- `pdf_utils.py`: Wrapper sobre Poppler para la rasterización de documentos PDF/TIFF.
|
||||
- `text_utils.py`: Herramientas de limpieza de texto y extracción robusta de bloques JSON.
|
||||
- `csv_utils.py`: Utilidades puras para aplanamiento de diccionarios anidados y generación de cabeceras.
|
||||
```bash
|
||||
curl http://localhost:8001/models
|
||||
```
|
||||
|
||||
**Capa GUI (`gui/`):**
|
||||
- Interfaz construida con `customtkinter`, implementando un patrón MVC básico (View, Controller).
|
||||
2. Descubrir campos:
|
||||
|
||||
### 📊 Logs y Depuración
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/schemas \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"document_path": "/data/radicado-1/factura.pdf"}'
|
||||
```
|
||||
|
||||
El sistema genera logs detallados en la carpeta `logs/` para auditoría y depuración:
|
||||
- `ips_extractor.log`: Log principal de la aplicación (formato JSON estructurado).
|
||||
- `llm.log`: Trazabilidad específica de las interacciones con el LLM (prompts y respuestas).
|
||||
- `api_calls.log`: Métricas de uso de API y tokens.
|
||||
3. Ejecutar extraccion:
|
||||
|
||||
Además, la salida por consola está optimizada con colores para una lectura rápida del estado del procesamiento.
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/batches \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"root_path": "/data",
|
||||
"keywords": ["factura"],
|
||||
"fields": ["numero_factura", "items/*/codigo", "items/*/valor"],
|
||||
"alias_map": {
|
||||
"numero_factura": "factura",
|
||||
"items/*/codigo": "codigo",
|
||||
"items/*/valor": "valor"
|
||||
},
|
||||
"structure_mode": "radicados"
|
||||
}'
|
||||
```
|
||||
|
||||
### 📝 Licencia
|
||||
Uso interno. Ajuste según sus necesidades.
|
||||
La respuesta devuelve `id`; ese valor es el `batch_id`.
|
||||
|
||||
4. Consultar filas extraidas:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/batches/{batch_id}/rows
|
||||
```
|
||||
|
||||
5. Ver reglas de limpieza:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8002/rules
|
||||
```
|
||||
|
||||
6. Analizar columnas extraidas:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8002/batches/{batch_id}/analysis
|
||||
```
|
||||
|
||||
7. Ejecutar limpieza:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8002/jobs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"extraction_batch_id": "{batch_id}",
|
||||
"rules_per_column": {
|
||||
"valor": ["to_integer"],
|
||||
"fecha": ["date_to_ddmmyyyy"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
La respuesta devuelve `id`; ese valor es el `job_id`.
|
||||
|
||||
8. Consultar filas limpias:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8002/jobs/{job_id}/rows
|
||||
```
|
||||
|
||||
### Flujo Por API Gateway
|
||||
|
||||
El gateway no importa codigo interno de los servicios; solo enruta HTTP.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/models
|
||||
curl http://localhost:8000/config
|
||||
curl http://localhost:8000/extraction/batches/{batch_id}/rows
|
||||
curl http://localhost:8000/cleaning/jobs/{job_id}/rows
|
||||
```
|
||||
|
||||
Para llamar cualquier endpoint propio de un servicio:
|
||||
|
||||
- `http://localhost:8000/extraction/...`
|
||||
- `http://localhost:8000/cleaning/...`
|
||||
|
||||
### Desarrollo Independiente
|
||||
|
||||
Extraccion como repo aparte:
|
||||
|
||||
```bash
|
||||
cd services/extraction
|
||||
pip install -r requirements.txt
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
Limpieza como repo aparte:
|
||||
|
||||
```bash
|
||||
cd services/cleaning
|
||||
pip install -r requirements.txt
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8002
|
||||
```
|
||||
|
||||
Gateway como repo aparte:
|
||||
|
||||
```bash
|
||||
cd services/api_gateway
|
||||
pip install -r requirements.txt
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Contrato De Base De Datos
|
||||
|
||||
Los servicios se integran por Postgres:
|
||||
|
||||
- `extraction_batches`
|
||||
- `extraction_rows`
|
||||
- `cleaning_jobs`
|
||||
- `cleaned_rows`
|
||||
|
||||
Cada servicio mantiene su propia copia del modelo SQLAlchemy para no depender
|
||||
de un paquete comun.
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Persistencia simple de preferencias de la GUI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .config import DEFAULT_ALLOWED_DOCUMENT_TYPES
|
||||
from .csv_cleaning import DEFAULT_MODERADORA_BY_YEAR, normalize_moderadora_by_year_dict
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PREFS_FILE = Path(__file__).resolve().parent.parent / "app_prefs.json"
|
||||
|
||||
|
||||
def moderadora_by_year_to_json_dict(
|
||||
data: dict[int, set[int]],
|
||||
) -> dict[str, list[int]]:
|
||||
"""Serializa año → conjunto de cuotas a JSON (claves str, listas ordenadas)."""
|
||||
out: dict[str, list[int]] = {}
|
||||
for y in sorted(data.keys()):
|
||||
out[str(y)] = sorted(data[y])
|
||||
return out
|
||||
|
||||
|
||||
def default_moderadora_by_year_payload() -> dict[str, list[int]]:
|
||||
"""Valores por defecto persistibles para cuota moderadora."""
|
||||
return moderadora_by_year_to_json_dict(DEFAULT_MODERADORA_BY_YEAR)
|
||||
|
||||
|
||||
def merge_moderadora_prefs(raw: Any) -> dict[str, list[int]]:
|
||||
"""Fusiona prefs guardadas con defaults; devuelve año(str) → lista."""
|
||||
base = default_moderadora_by_year_payload()
|
||||
if not isinstance(raw, dict):
|
||||
return base
|
||||
parsed = normalize_moderadora_by_year_dict(raw)
|
||||
for y, vals in parsed.items():
|
||||
base[str(y)] = sorted(vals)
|
||||
return base
|
||||
|
||||
|
||||
def format_moderadora_text_from_prefs(payload: dict[str, list[int]]) -> str:
|
||||
"""Una línea por año: `2024: 4500, 18200, 47700`."""
|
||||
lines: list[str] = []
|
||||
for ks in sorted(payload.keys(), key=lambda x: int(x)):
|
||||
nums = payload[ks]
|
||||
lines.append(f"{ks}: {', '.join(str(n) for n in nums)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_moderadora_text(text: str) -> dict[str, list[int]]:
|
||||
"""Parsea líneas `año: n1, n2` o `año = n1, n2`. Ignora vacías y errores."""
|
||||
out: dict[str, list[int]] = {}
|
||||
for line in (text or "").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^\s*(\d{4})\s*[:=]\s*(.+)$", line)
|
||||
if not m:
|
||||
continue
|
||||
yk = m.group(1)
|
||||
rest = m.group(2)
|
||||
nums: list[int] = []
|
||||
for part in re.split(r"[,;\s]+", rest):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
nums.append(int(round(float(part))))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if nums:
|
||||
out[yk] = nums
|
||||
return out
|
||||
|
||||
|
||||
def load_cop_mod_preferences() -> tuple[dict[str, list[int]], str]:
|
||||
"""Lee prefs para COP/MOD: mapa año→lista y nombre sugerido columna fecha."""
|
||||
prefs = load_app_prefs()
|
||||
merged = merge_moderadora_prefs(prefs.get("moderadora_by_year"))
|
||||
fecha_default = str(prefs.get("fecha_servicio_column_default") or "").strip()
|
||||
return merged, fecha_default
|
||||
|
||||
|
||||
def build_cop_mod_config_for_apply(
|
||||
moderadora_payload: Optional[dict[str, list[int]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Construye el dict opcional para `csv_cleaning.apply_rules(..., cop_mod_config=)`."""
|
||||
if moderadora_payload is None:
|
||||
prefs = load_app_prefs()
|
||||
merged = merge_moderadora_prefs(prefs.get("moderadora_by_year"))
|
||||
else:
|
||||
merged = merge_moderadora_prefs(moderadora_payload)
|
||||
return {"moderadora_by_year": merged}
|
||||
|
||||
|
||||
def load_app_prefs() -> Dict[str, Any]:
|
||||
"""Carga preferencias desde disco si existen."""
|
||||
try:
|
||||
if not PREFS_FILE.exists():
|
||||
return {}
|
||||
raw = PREFS_FILE.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
logger.exception("No se pudieron cargar las preferencias de la app")
|
||||
return {}
|
||||
|
||||
|
||||
def save_app_prefs(data: Dict[str, Any]) -> None:
|
||||
"""Guarda preferencias de la app en JSON."""
|
||||
try:
|
||||
PREFS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
logger.exception("No se pudieron guardar las preferencias de la app")
|
||||
|
||||
|
||||
def normalize_document_types(values: Any) -> list[str]:
|
||||
"""Normaliza una lista editable de tipos documentales."""
|
||||
raw_values = values if isinstance(values, list) else []
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_values:
|
||||
text = str(item or "").strip().upper()
|
||||
if not text or text == "NIT" or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
normalized.append(text)
|
||||
return normalized or list(DEFAULT_ALLOWED_DOCUMENT_TYPES)
|
||||
|
||||
|
||||
def get_allowed_document_types() -> tuple[str, ...]:
|
||||
"""Devuelve la lista persistida de tipos documentales válidos."""
|
||||
prefs = load_app_prefs()
|
||||
return tuple(normalize_document_types(prefs.get("allowed_document_types")))
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Configuración centralizada de la aplicación.
|
||||
|
||||
Usa Pydantic BaseSettings para cargar valores por defecto y variables de entorno.
|
||||
Se lee automáticamente un archivo `.env` en la raíz del proyecto si existe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Cargar variables desde .env en el entorno (para que otros módulos que usan os.environ las vean)
|
||||
try:
|
||||
from dotenv import load_dotenv # type: ignore
|
||||
# En desarrollo, dar precedencia al archivo .env sobre variables previas del entorno
|
||||
load_dotenv(override=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import AliasChoices
|
||||
|
||||
|
||||
class ApiSettings(BaseSettings):
|
||||
"""Parámetros del backend compatible con OpenAI/Ollama (usando os.getenv)."""
|
||||
base_url: str = os.getenv("EXTRACTOR_BASE_URL", "http://10.8.0.4:11434/v1")
|
||||
api_key: str = os.getenv("EXTRACTOR_API_KEY", "ollama")
|
||||
model: str = os.getenv("EXTRACTOR_MODEL", "gpt-5.4")
|
||||
request_timeout_s: int = int(os.getenv("EXTRACTOR_TIMEOUT_S", 120))
|
||||
grok_api_key: str = os.getenv("GROK_API_KEY", "")
|
||||
grok_base_url: str = os.getenv("GROK_BASE_URL", "https://api.x.ai/v1")
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
class ImagingSettings(BaseSettings):
|
||||
"""Parámetros de conversión de documentos a imágenes."""
|
||||
poppler_path: Optional[str] = Field(None, validation_alias=AliasChoices("POPPLER_PATH"))
|
||||
pdf_dpi: int = Field(600, validation_alias=AliasChoices("PDF_DPI"))
|
||||
pdf_thread_count: int = Field(10, validation_alias=AliasChoices("PDF_THREAD_COUNT"))
|
||||
tiff_pages: Optional[Tuple[int, int]] = Field((2, 3), validation_alias=AliasChoices("TIFF_PAGES"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
class RetrySettings(BaseSettings):
|
||||
"""Estrategia de reintentos para llamadas al backend."""
|
||||
max_attempts: int = Field(3, validation_alias=AliasChoices("RETRY_MAX_ATTEMPTS"))
|
||||
wait_min_s: float = Field(1.0, validation_alias=AliasChoices("RETRY_WAIT_MIN_S"))
|
||||
wait_max_s: float = Field(8.0, validation_alias=AliasChoices("RETRY_WAIT_MAX_S"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
class ProcessingSettings(BaseSettings):
|
||||
"""Configuración de procesamiento y concurrencia."""
|
||||
concurrency_limit: int = Field(100, validation_alias=AliasChoices("CONCURRENCY_LIMIT"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
"""Configuración raíz agrupada."""
|
||||
api: ApiSettings = ApiSettings()
|
||||
imaging: ImagingSettings = ImagingSettings()
|
||||
retry: RetrySettings = RetrySettings()
|
||||
processing: ProcessingSettings = ProcessingSettings()
|
||||
csv_delimiter: str = Field("|", validation_alias=AliasChoices("CSV_DELIMITER"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
|
||||
def extractor_credentials_for_model(model: str) -> tuple[str, str]:
|
||||
"""Devuelve (api_key, base_url) para el extractor según el id de modelo."""
|
||||
m = (model or "").strip()
|
||||
api = settings.api
|
||||
if m.lower().startswith("grok-"):
|
||||
return (str(api.grok_api_key or "").strip(), str(api.grok_base_url or "").strip() or "https://api.x.ai/v1")
|
||||
return (api.api_key, api.base_url)
|
||||
|
||||
|
||||
def is_grok_extractor_model(model: str) -> bool:
|
||||
"""True si el modelo de extracción debe usar la API de xAI (Grok)."""
|
||||
return (model or "").strip().lower().startswith("grok-")
|
||||
|
||||
|
||||
# Tipos documentales válidos por defecto para el campo especial `documento_forzate`.
|
||||
# NIT se excluye explícitamente porque no debe tratarse como tipo de documento personal.
|
||||
DEFAULT_ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = (
|
||||
"CC",
|
||||
"TI",
|
||||
"CE",
|
||||
"RC",
|
||||
"PA",
|
||||
"PPT",
|
||||
"PEP",
|
||||
"EPT",
|
||||
"NUIP",
|
||||
)
|
||||
|
||||
# Alias de compatibilidad.
|
||||
ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = DEFAULT_ALLOWED_DOCUMENT_TYPES
|
||||
@@ -1,281 +0,0 @@
|
||||
import sqlite3
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseManager:
|
||||
"""
|
||||
Gestiona la persistencia de resultados de preclasificación en una base de datos SQLite.
|
||||
|
||||
Estructura de tablas:
|
||||
- runs: Registra cada ejecución global (fecha, ruta raíz, configuración).
|
||||
- results: Almacena el estado de procesamiento de cada archivo individual.
|
||||
- matches: Detalle de campos específicos encontrados en cada archivo.
|
||||
"""
|
||||
def __init__(self, db_path: str = "preclassification.db"):
|
||||
self.db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
"""
|
||||
Inicializa el esquema de la base de datos si no existe.
|
||||
Crea las tablas 'runs', 'results' y 'matches' con sus respectivas llaves foráneas.
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
# Tabla de ejecuciones
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL,
|
||||
config TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Tabla de resultados por archivo/radicado
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
radicado TEXT NOT NULL,
|
||||
file_path TEXT,
|
||||
status TEXT NOT NULL, -- 'MATCH', 'NO_MATCH', 'ERROR'
|
||||
raw_response TEXT,
|
||||
FOREIGN KEY(run_id) REFERENCES runs(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Tabla de coincidencias detalladas (campo por campo)
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
result_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
expected_value TEXT,
|
||||
found_value TEXT,
|
||||
exists_bool BOOLEAN NOT NULL,
|
||||
FOREIGN KEY(result_id) REFERENCES results(id)
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error inicializando base de datos: {e}")
|
||||
|
||||
def create_run(self, root_path: str, config: Dict[str, Any]) -> int:
|
||||
"""
|
||||
Registra una nueva sesión de procesamiento.
|
||||
|
||||
Args:
|
||||
root_path: Directorio raíz procesado.
|
||||
config: Diccionario de configuración usado (se guarda como JSON).
|
||||
|
||||
Returns:
|
||||
ID de la ejecución creada.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
c.execute('INSERT INTO runs (timestamp, root_path, config) VALUES (?, ?, ?)',
|
||||
(timestamp, root_path, json.dumps(config)))
|
||||
run_id = c.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return run_id
|
||||
|
||||
def add_result(self, run_id: int, radicado: str, file_path: str, status: str, raw_response: str) -> int:
|
||||
"""
|
||||
Añade el resultado del procesamiento de un archivo.
|
||||
|
||||
Args:
|
||||
run_id: ID de la ejecución padre.
|
||||
radicado: Identificador del radicado.
|
||||
file_path: Ruta al archivo físico.
|
||||
status: Estado final ('MATCH', 'NO_MATCH', 'ERROR').
|
||||
raw_response: Respuesta completa del modelo.
|
||||
|
||||
Returns:
|
||||
ID del resultado insertado.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('INSERT INTO results (run_id, radicado, file_path, status, raw_response) VALUES (?, ?, ?, ?, ?)',
|
||||
(run_id, radicado, file_path, status, raw_response))
|
||||
result_id = c.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return result_id
|
||||
|
||||
def add_match(self, result_id: int, key: str, expected_value: Optional[str], found_value: Optional[str], exists: bool):
|
||||
"""
|
||||
Registra el hallazgo (o ausencia) de un campo específico en un documento.
|
||||
|
||||
Args:
|
||||
result_id: ID del resultado al que pertenece.
|
||||
key: Nombre del campo/llave.
|
||||
expected_value: Valor que se esperaba encontrar (si aplica).
|
||||
found_value: Valor extraído por el modelo.
|
||||
exists: Booleano que indica si el campo fue efectivamente localizado.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('INSERT INTO matches (result_id, key, expected_value, found_value, exists_bool) VALUES (?, ?, ?, ?, ?)',
|
||||
(result_id, key, expected_value, found_value, exists))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_run_results(self, run_id: int) -> List[Dict[str, Any]]:
|
||||
"""Obtiene resultados completos de una ejecución."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''
|
||||
SELECT r.radicado, r.file_path, r.status, m.key, m.expected_value, m.found_value, m.exists_bool
|
||||
FROM results r
|
||||
LEFT JOIN matches m ON r.id = m.result_id
|
||||
WHERE r.run_id = ?
|
||||
''', (run_id,))
|
||||
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_all_runs(self) -> List[Dict[str, Any]]:
|
||||
"""Obtiene lista de todos los runs ordenados por fecha descendente."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT * FROM runs ORDER BY timestamp DESC")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def _search_results_query(
|
||||
self, run_id: Optional[int] = None, status: Optional[str] = None, text: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Una búsqueda; todos los criterios activos se combinan con AND.
|
||||
|
||||
Orden de importancia usado al relajar (en `search_results`):
|
||||
prioridad 1: run, 2: estado, 3: texto. No se suelta el criterio de
|
||||
`run` al relajar — solo texto y, si aplica, estado.
|
||||
"""
|
||||
tx = (text or "").strip() or None
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
query = '''
|
||||
SELECT r.id, r.run_id, r.radicado, r.file_path, r.status, r.raw_response, runs.timestamp
|
||||
FROM results r
|
||||
JOIN runs ON r.run_id = runs.id
|
||||
WHERE 1=1
|
||||
'''
|
||||
params: list = []
|
||||
if run_id is not None:
|
||||
query += " AND r.run_id = ?"
|
||||
params.append(run_id)
|
||||
if status and status != "Todos":
|
||||
query += " AND r.status = ?"
|
||||
params.append(status)
|
||||
if tx:
|
||||
query += " AND (r.radicado LIKE ? OR r.file_path LIKE ?)"
|
||||
wild = f"%{tx}%"
|
||||
params.extend([wild, wild])
|
||||
query += " ORDER BY r.id DESC LIMIT 1000"
|
||||
c.execute(query, params)
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def search_results(
|
||||
self,
|
||||
run_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
text: Optional[str] = None,
|
||||
relax_if_empty: bool = False,
|
||||
) -> tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""Busca resultados; opcionalmente relaja criterios si no hay filas.
|
||||
|
||||
Prioridad: **run (si aplica) > estado > criterio de búsqueda (texto)**.
|
||||
Con `relax_if_empty=True`, se omite el texto, luego el estado; el
|
||||
filtro de run fijado no se omite.
|
||||
|
||||
Devuelve (filas, nota) donde `nota` informa criterios omitidos al
|
||||
relajar, o `None` si se usó la consulta estricta o no aplica.
|
||||
"""
|
||||
had_text = bool((text or "").strip())
|
||||
had_status = bool(status and status != "Todos")
|
||||
|
||||
rows = self._search_results_query(run_id, status, text)
|
||||
if rows or not relax_if_empty:
|
||||
return (rows, None)
|
||||
if not had_text and not had_status:
|
||||
return ([], None)
|
||||
if had_text:
|
||||
looser = self._search_results_query(run_id, status, None)
|
||||
if looser:
|
||||
return (
|
||||
looser,
|
||||
"Búsqueda ampliada: se omitió el criterio de búsqueda (texto) "
|
||||
"(el de menor importancia: run > estado > texto; el run no se descarta al relajar).",
|
||||
)
|
||||
if had_status:
|
||||
looser2 = self._search_results_query(run_id, None, None)
|
||||
if looser2:
|
||||
if had_text:
|
||||
nota = (
|
||||
"Búsqueda ampliada: se omitieron el criterio de búsqueda (texto) y el filtro de estado, "
|
||||
"en ese orden, por prioridad (run > estado > texto; el run no se descarta al relajar)."
|
||||
)
|
||||
else:
|
||||
nota = (
|
||||
"Búsqueda ampliada: se omitió el filtro de estado, "
|
||||
"criterio más débil al combinar con un run. Prioridad: run > estado > búsqueda; "
|
||||
"el run no se descarta al relajar."
|
||||
)
|
||||
return (looser2, nota)
|
||||
return ([], None)
|
||||
|
||||
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)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT * FROM matches WHERE result_id = ?", (result_id,))
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_run(self, run_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Obtiene un run por ID (incluye config como dict si aplica)."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT * FROM runs WHERE id = ?", (run_id,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
try:
|
||||
cfg_raw = d.get("config")
|
||||
d["config"] = json.loads(cfg_raw) if cfg_raw else {}
|
||||
except Exception:
|
||||
d["config"] = {}
|
||||
return d
|
||||
@@ -1,254 +0,0 @@
|
||||
"""Agente inteligente de post-procesamiento de CSV.
|
||||
|
||||
Genera código Python con un LLM para transformar DataFrames según
|
||||
instrucciones del usuario en lenguaje natural.
|
||||
Usa la API de Responses de OpenAI (requerida por modelos codex).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from openai import OpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POSTPROCESS_MODEL = "gpt-5.1-codex-mini"
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
Eres un agente experto en transformación de datos con pandas.
|
||||
|
||||
REGLAS ESTRICTAS:
|
||||
1. Recibes una MUESTRA representativa de un CSV y una instrucción del usuario.
|
||||
2. Debes devolver ÚNICAMENTE un bloque de código Python válido dentro de ```python ... ```.
|
||||
3. El código opera sobre una variable `df` que es un `pd.DataFrame` ya cargado.
|
||||
4. El código debe modificar `df` in-place o reasignar a `df`. Al final, `df` debe contener el resultado.
|
||||
5. Solo puedes usar las siguientes librerías (ya importadas): `pd` (pandas), `np` (numpy), `re`, `datetime`.
|
||||
6. NO puedes usar: open, exec, eval, __import__, compile, subprocess, os, sys, requests, ni ninguna forma de I/O o acceso al sistema.
|
||||
7. El código debe ser conciso y eficiente.
|
||||
8. NO incluyas explicaciones fuera del bloque de código.
|
||||
"""
|
||||
|
||||
RETRY_TEMPLATE = """\
|
||||
El código que generaste produjo el siguiente error al ejecutarse:
|
||||
|
||||
CÓDIGO:
|
||||
```python
|
||||
{code}
|
||||
```
|
||||
|
||||
ERROR:
|
||||
{error}
|
||||
|
||||
INSTRUCCIÓN ORIGINAL DEL USUARIO:
|
||||
{instructions}
|
||||
|
||||
MUESTRA DEL CSV:
|
||||
{sample}
|
||||
|
||||
Por favor genera un código corregido que solucione el error. Devuelve ÚNICAMENTE el bloque ```python ... ```.
|
||||
"""
|
||||
|
||||
|
||||
def sample_dataframe(df: pd.DataFrame, n: int = 5) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Genera una muestra inteligente del DataFrame.
|
||||
|
||||
Retorna (sample_text, column_info) donde sample_text es una
|
||||
representación legible y column_info contiene metadatos.
|
||||
"""
|
||||
total_rows = len(df)
|
||||
total_cols = len(df.columns)
|
||||
|
||||
col_info = {
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"columns": {col: str(df[col].dtype) for col in df.columns},
|
||||
}
|
||||
|
||||
used_indices: set[int] = set()
|
||||
sections: List[str] = []
|
||||
|
||||
sections.append(
|
||||
f"Total filas: {total_rows:,} | Total columnas: {total_cols}\n"
|
||||
f"Columnas y tipos: {', '.join(f'{c} ({t})' for c, t in col_info['columns'].items())}"
|
||||
)
|
||||
|
||||
head_n = min(n, total_rows)
|
||||
head_idx = list(range(head_n))
|
||||
used_indices.update(head_idx)
|
||||
sections.append(f"--- Primeras {head_n} filas ---\n{df.iloc[head_idx].to_csv(index=False)}")
|
||||
|
||||
if total_rows > n:
|
||||
tail_start = max(total_rows - n, head_n)
|
||||
tail_idx = list(range(tail_start, total_rows))
|
||||
new_tail = [i for i in tail_idx if i not in used_indices]
|
||||
if new_tail:
|
||||
used_indices.update(new_tail)
|
||||
sections.append(f"--- Últimas {len(new_tail)} filas ---\n{df.iloc[new_tail].to_csv(index=False)}")
|
||||
|
||||
if total_rows > 2 * n:
|
||||
mid_center = total_rows // 2
|
||||
mid_start = max(mid_center - n // 2, 0)
|
||||
mid_idx = list(range(mid_start, min(mid_start + n, total_rows)))
|
||||
new_mid = [i for i in mid_idx if i not in used_indices]
|
||||
if new_mid:
|
||||
used_indices.update(new_mid)
|
||||
sections.append(f"--- Filas del medio ---\n{df.iloc[new_mid].to_csv(index=False)}")
|
||||
|
||||
remaining = [i for i in range(total_rows) if i not in used_indices]
|
||||
if remaining:
|
||||
rand_n = min(n, len(remaining))
|
||||
rng = np.random.default_rng(42)
|
||||
rand_idx = sorted(rng.choice(remaining, size=rand_n, replace=False).tolist())
|
||||
used_indices.update(rand_idx)
|
||||
sections.append(f"--- {rand_n} filas aleatorias ---\n{df.iloc[rand_idx].to_csv(index=False)}")
|
||||
|
||||
sample_text = "\n\n".join(sections)
|
||||
return sample_text, col_info
|
||||
|
||||
|
||||
def _extract_python_code(text: str) -> str:
|
||||
"""Extrae el bloque de código Python de la respuesta del LLM."""
|
||||
pattern = r"```python\s*\n(.*?)```"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
pattern2 = r"```\s*\n(.*?)```"
|
||||
match2 = re.search(pattern2, text, re.DOTALL)
|
||||
if match2:
|
||||
return match2.group(1).strip()
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _call_responses_api(
|
||||
client: OpenAI,
|
||||
instructions: str,
|
||||
user_input: str,
|
||||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
"""Llama a la API de Responses (requerida por modelos codex)."""
|
||||
response = client.responses.create(
|
||||
model=POSTPROCESS_MODEL,
|
||||
instructions=instructions,
|
||||
input=user_input,
|
||||
)
|
||||
usage = None
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if raw_usage is not None:
|
||||
usage = {
|
||||
"input_tokens": getattr(raw_usage, "input_tokens", None),
|
||||
"output_tokens": getattr(raw_usage, "output_tokens", None),
|
||||
"total_tokens": getattr(raw_usage, "total_tokens", None),
|
||||
}
|
||||
return response.output_text, usage
|
||||
|
||||
|
||||
def generate_code(
|
||||
client: OpenAI,
|
||||
csv_sample: str,
|
||||
user_instructions: str,
|
||||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
"""Genera código Python invocando al LLM via Responses API.
|
||||
|
||||
Retorna (code_string, usage_dict).
|
||||
"""
|
||||
user_input = (
|
||||
f"MUESTRA DEL CSV:\n{csv_sample}\n\n"
|
||||
f"INSTRUCCIONES:\n{user_instructions}"
|
||||
)
|
||||
raw_text, usage = _call_responses_api(client, SYSTEM_PROMPT, user_input)
|
||||
code = _extract_python_code(raw_text)
|
||||
return code, usage
|
||||
|
||||
|
||||
def _build_safe_builtins() -> dict:
|
||||
import builtins as _b
|
||||
blocked = {"open", "exec", "eval", "__import__", "compile",
|
||||
"exit", "quit", "breakpoint", "input"}
|
||||
return {k: v for k, v in vars(_b).items() if k not in blocked}
|
||||
|
||||
_SAFE_BUILTINS = _build_safe_builtins()
|
||||
|
||||
|
||||
def execute_code(
|
||||
code: str,
|
||||
df: pd.DataFrame,
|
||||
timeout_s: float = 60,
|
||||
) -> Tuple[Optional[pd.DataFrame], Optional[str]]:
|
||||
"""Ejecuta código Python generado en un entorno restringido.
|
||||
|
||||
Retorna (df_result, error_message). Si error_message es None, la
|
||||
ejecución fue exitosa.
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
df_copy = df.copy()
|
||||
namespace: Dict[str, Any] = {
|
||||
"__builtins__": _SAFE_BUILTINS,
|
||||
"pd": pd,
|
||||
"np": np,
|
||||
"re": re,
|
||||
"datetime": _dt,
|
||||
"df": df_copy,
|
||||
}
|
||||
|
||||
result_holder: Dict[str, Any] = {"df": None, "error": None}
|
||||
|
||||
def _run():
|
||||
try:
|
||||
exec(code, namespace) # noqa: S102
|
||||
result_holder["df"] = namespace.get("df", df_copy)
|
||||
except Exception as exc:
|
||||
result_holder["error"] = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=timeout_s)
|
||||
|
||||
if thread.is_alive():
|
||||
return None, "Timeout: la ejecución excedió el límite de tiempo."
|
||||
|
||||
if result_holder["error"]:
|
||||
return None, result_holder["error"]
|
||||
|
||||
result_df = result_holder["df"]
|
||||
if not isinstance(result_df, pd.DataFrame):
|
||||
return None, "El código no produjo un DataFrame válido en la variable `df`."
|
||||
|
||||
return result_df, None
|
||||
|
||||
|
||||
def retry_with_error(
|
||||
client: OpenAI,
|
||||
csv_sample: str,
|
||||
user_instructions: str,
|
||||
failed_code: str,
|
||||
error_msg: str,
|
||||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
"""Re-envía al LLM el código fallido y el error para corrección."""
|
||||
retry_content = RETRY_TEMPLATE.format(
|
||||
code=failed_code,
|
||||
error=error_msg,
|
||||
instructions=user_instructions,
|
||||
sample=csv_sample,
|
||||
)
|
||||
raw_text, usage = _call_responses_api(client, SYSTEM_PROMPT, retry_content)
|
||||
code = _extract_python_code(raw_text)
|
||||
return code, usage
|
||||
|
||||
|
||||
def detect_delimiter(filepath: str) -> str:
|
||||
"""Detecta el delimitador de un archivo CSV leyendo las primeras líneas."""
|
||||
import csv as _csv
|
||||
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
sample = f.read(8192)
|
||||
|
||||
try:
|
||||
dialect = _csv.Sniffer().sniff(sample, delimiters=",;\t|")
|
||||
return dialect.delimiter
|
||||
except _csv.Error:
|
||||
return ","
|
||||
@@ -1,448 +0,0 @@
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PreclassificationService:
|
||||
"""
|
||||
Servicio para la preclasificación masiva de documentos.
|
||||
|
||||
Permite identificar si un documento cumple con ciertos criterios (patterns)
|
||||
y extraer información clave para organizar los archivos físicamente en carpetas.
|
||||
"""
|
||||
def __init__(self, extractor: OllamaExtractor):
|
||||
self.extractor = extractor
|
||||
self.db = DatabaseManager()
|
||||
|
||||
def export_to_excel(self, run_id: int, output_path: str) -> bool:
|
||||
"""
|
||||
Exporta los resultados de una ejecución de preclasificación a Excel o CSV.
|
||||
|
||||
Args:
|
||||
run_id: ID de la ejecución en la base de datos.
|
||||
output_path: Ruta del archivo de salida (.xlsx o .csv).
|
||||
|
||||
Returns:
|
||||
True si la exportación fue exitosa, False si no hay datos.
|
||||
"""
|
||||
try:
|
||||
results = self.db.get_run_results(run_id)
|
||||
if not results:
|
||||
return False
|
||||
|
||||
# Pivotar para tener filas únicas por archivo
|
||||
# get_run_results devuelve una fila por cada key encontrada en cada archivo
|
||||
# Necesitamos agrupar por (radicado, file_path)
|
||||
|
||||
data_map = {}
|
||||
keys_seen = set()
|
||||
|
||||
for row in results:
|
||||
file_id = f"{row['radicado']}|{row['file_path']}"
|
||||
if file_id not in data_map:
|
||||
data_map[file_id] = {
|
||||
"Radicado": row["radicado"],
|
||||
"Archivo": row["file_path"],
|
||||
"Status": row["status"]
|
||||
}
|
||||
|
||||
k = row["key"]
|
||||
if k:
|
||||
# found_value puede ser None
|
||||
val = row["found_value"]
|
||||
exists = row["exists_bool"]
|
||||
# Podemos poner el valor o un indicador
|
||||
data_map[file_id][k] = val if exists else "NO_FOUND"
|
||||
keys_seen.add(k)
|
||||
|
||||
# Convertir a lista
|
||||
final_data = list(data_map.values())
|
||||
|
||||
df = pd.DataFrame(final_data)
|
||||
|
||||
# Asegurar todas las columnas de keys
|
||||
for k in keys_seen:
|
||||
if k not in df.columns:
|
||||
df[k] = None
|
||||
|
||||
if output_path.endswith(".csv"):
|
||||
df.to_csv(output_path, index=False, encoding="utf-8-sig", sep=";")
|
||||
else:
|
||||
df.to_excel(output_path, index=False)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception("Error exportando a Excel")
|
||||
raise e
|
||||
|
||||
def organize_files(self, run_id: int, base_output_path: str) -> tuple[int, int]:
|
||||
"""
|
||||
Copia físicamente los archivos que hicieron 'MATCH' a una nueva estructura de carpetas.
|
||||
La jerarquía se determina automáticamente basada en los valores extraídos.
|
||||
|
||||
Args:
|
||||
run_id: ID de la ejecución.
|
||||
base_output_path: Directorio donde se creará la nueva estructura.
|
||||
|
||||
Returns:
|
||||
Tupla (total_copiados, total_errores).
|
||||
"""
|
||||
run = self.db.get_run(run_id)
|
||||
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"
|
||||
|
||||
results = self.db.get_run_results(run_id)
|
||||
# Filtrar solo MATCH
|
||||
match_rows = [r for r in results if r["status"] == "MATCH"]
|
||||
|
||||
if not match_rows:
|
||||
return 0, 0
|
||||
|
||||
# Agrupar por archivo para tener el "set" de datos de cada uno
|
||||
files_data = {} # { file_path: {key: val, ...}, 'radicado': ... }
|
||||
key_values_all = {} # { key: set(values) }
|
||||
|
||||
for row in match_rows:
|
||||
fpath = row["file_path"]
|
||||
if not fpath: continue
|
||||
|
||||
if fpath not in files_data:
|
||||
files_data[fpath] = {"radicado": row["radicado"], "keys": {}}
|
||||
|
||||
k = row["key"]
|
||||
v = row["found_value"]
|
||||
if k and v:
|
||||
files_data[fpath]["keys"][k] = v
|
||||
|
||||
if k not in key_values_all:
|
||||
key_values_all[k] = set()
|
||||
key_values_all[k].add(v)
|
||||
|
||||
# Determinar orden de jerarquía por cardinalidad
|
||||
# Lista de (key, cardinalidad)
|
||||
hierarchy = []
|
||||
for k, val_set in key_values_all.items():
|
||||
hierarchy.append((k, len(val_set)))
|
||||
|
||||
# Ordenar ascendente: primero los que tienen menos valores (ej. Categorias generales)
|
||||
hierarchy.sort(key=lambda x: x[1])
|
||||
ordered_keys = [x[0] for x in hierarchy]
|
||||
|
||||
logger.info(f"Jerarquía de carpetas calculada: {ordered_keys}")
|
||||
|
||||
count_ok = 0
|
||||
count_err = 0
|
||||
|
||||
for fpath, info in files_data.items():
|
||||
try:
|
||||
# Construir ruta relativa
|
||||
# Sanitizar valores para que sean nombres de carpeta válidos
|
||||
path_parts = []
|
||||
for k in ordered_keys:
|
||||
val = info["keys"].get(k, "DESCONOCIDO")
|
||||
safe_val = self._sanitize_filename(str(val))
|
||||
|
||||
# KeyName / ValueName
|
||||
safe_key = self._sanitize_filename(k)
|
||||
path_parts.append(safe_key)
|
||||
path_parts.append(safe_val)
|
||||
|
||||
# Añadir radicado al final solo si NO es modo general (en general el radicado es el archivo)
|
||||
# Si mode es radicados: ... / Radicado / Archivo
|
||||
# Si mode es general: ... / Archivo
|
||||
if mode != "general":
|
||||
path_parts.append(self._sanitize_filename(info["radicado"]))
|
||||
|
||||
dest_dir = os.path.join(base_output_path, *path_parts)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
fname = os.path.basename(fpath)
|
||||
dest_file = os.path.join(dest_dir, fname)
|
||||
# Evitar sobrescrituras (especialmente en modo general sin radicado)
|
||||
if os.path.exists(dest_file):
|
||||
base, ext = os.path.splitext(fname)
|
||||
n = 1
|
||||
while True:
|
||||
candidate = os.path.join(dest_dir, f"{base}_{n}{ext}")
|
||||
if not os.path.exists(candidate):
|
||||
dest_file = candidate
|
||||
break
|
||||
n += 1
|
||||
|
||||
# Copiar
|
||||
shutil.copy2(fpath, dest_file)
|
||||
count_ok += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error organizando {fpath}: {e}")
|
||||
count_err += 1
|
||||
|
||||
return count_ok, count_err
|
||||
|
||||
def _sanitize_filename(self, name: str) -> str:
|
||||
"""Limpia caracteres inválidos para rutas de archivo en Windows/Linux."""
|
||||
# Reemplazar caracteres no alfanuméricos (excepto espacio, guion, punto)
|
||||
clean = re.sub(r'[\\/*?:"<>|]', "", name)
|
||||
clean = clean.replace("\n", " ").replace("\r", "").strip()
|
||||
if not clean:
|
||||
return "UNTITLED"
|
||||
return clean[:50] # Limitar longitud
|
||||
|
||||
def run_preclassification(
|
||||
self,
|
||||
root_path: str,
|
||||
patterns: List[Dict[str, str]], # [{key, mode, value?}, ...]
|
||||
target_info: Dict[str, Any], # {enabled, definition, pattern}
|
||||
page_range: Tuple[bool, int, int], # (enabled, start, end)
|
||||
on_progress: Optional[Callable[[str, int, int], None]] = None,
|
||||
mode: str = "radicados" # "radicados" | "general"
|
||||
) -> int:
|
||||
"""
|
||||
Ejecuta el flujo completo de preclasificación sobre un directorio.
|
||||
|
||||
Args:
|
||||
root_path: Directorio con los documentos.
|
||||
patterns: Reglas de validación (ej. buscar NIT, buscar Fecha).
|
||||
target_info: Filtro de archivos por nombre/patrón.
|
||||
page_range: Rango de páginas a procesar por documento.
|
||||
on_progress: Callback para reportar avance a la GUI.
|
||||
mode: 'radicados' (carpetas) o 'general' (archivos sueltos).
|
||||
|
||||
Returns:
|
||||
ID de la ejecución (run_id) generada.
|
||||
"""
|
||||
# 1. Crear Run en BD
|
||||
mode = str(mode or "radicados").strip().lower()
|
||||
if mode not in ("radicados", "general"):
|
||||
mode = "radicados"
|
||||
|
||||
run_config = {
|
||||
"patterns": patterns,
|
||||
"target": target_info,
|
||||
"range": page_range,
|
||||
"mode": mode
|
||||
}
|
||||
run_id = self.db.create_run(root_path, run_config)
|
||||
|
||||
# 2. Descubrir items a procesar
|
||||
radicados = []
|
||||
if os.path.isdir(root_path):
|
||||
if mode == "general":
|
||||
# En modo general, consideramos que la raiz es el único "contenedor"
|
||||
# o iteramos sobre una lista dummy.
|
||||
# Para simplificar el loop principal, usaremos una lista con un elemento "."
|
||||
radicados = ["."]
|
||||
else:
|
||||
radicados = sorted([
|
||||
d for d in os.listdir(root_path)
|
||||
if os.path.isdir(os.path.join(root_path, d))
|
||||
])
|
||||
|
||||
total = len(radicados)
|
||||
|
||||
# Preparar prompt
|
||||
keys_to_extract = [p["key"] for p in patterns]
|
||||
prompt = self._build_prompt(keys_to_extract)
|
||||
|
||||
for idx, rad in enumerate(radicados, start=1):
|
||||
# Si es general, rad es ".", no es util para el usuario ver "Procesando ."
|
||||
# Ajustaremos el reporte dentro del loop de archivos si es general
|
||||
if mode != "general" and on_progress:
|
||||
on_progress(rad, idx, total)
|
||||
|
||||
rad_path = os.path.join(root_path, rad)
|
||||
|
||||
# 3. Identificar archivos a procesar
|
||||
files_to_process = self._identify_files(rad_path, target_info, mode)
|
||||
logger.info(f"Radicado/Carpeta: {rad} - Archivos identificados: {len(files_to_process)}")
|
||||
|
||||
# Ordenar archivos para consistencia
|
||||
files_to_process.sort()
|
||||
|
||||
if not files_to_process:
|
||||
if mode == "general":
|
||||
# Registrar una sola vez que no había archivos
|
||||
self.db.add_result(run_id, "GENERAL", None, "NO_FILES", "")
|
||||
return run_id
|
||||
else:
|
||||
self.db.add_result(run_id, rad, None, "NO_FILES", "")
|
||||
continue
|
||||
|
||||
total_files = len(files_to_process)
|
||||
for f_idx, file_path in enumerate(files_to_process, 1):
|
||||
# En modo general, el "radicado" lógico será el nombre del archivo sin extensión
|
||||
# En modo radicados, es el nombre de la carpeta (variable rad)
|
||||
current_radicado = rad
|
||||
|
||||
# Definir radicado para modo general ANTES de cualquier lógica
|
||||
if mode == "general":
|
||||
base_name = os.path.basename(file_path)
|
||||
name_no_ext = os.path.splitext(base_name)[0]
|
||||
# Validar que no quede vacio ni sea un punto
|
||||
if name_no_ext and name_no_ext.strip() not in (".", ""):
|
||||
current_radicado = name_no_ext.strip()
|
||||
else:
|
||||
current_radicado = base_name
|
||||
|
||||
if str(current_radicado).strip() in (".", ""):
|
||||
current_radicado = f"FILE_{int(time.time()*1000)}_{f_idx}"
|
||||
|
||||
logger.info(f"Procesando archivo [{f_idx}/{total_files}]: {file_path} -> Radicado: {current_radicado}")
|
||||
if on_progress:
|
||||
try:
|
||||
# Asegurar que pasamos un string válido
|
||||
safe_rad = str(current_radicado)
|
||||
on_progress(safe_rad, f_idx, total_files)
|
||||
except Exception:
|
||||
pass # Evitar romper el loop si la GUI falla
|
||||
|
||||
try:
|
||||
# 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:
|
||||
self.db.add_result(run_id, rad, file_path, "ERROR_IMG", "No images generated")
|
||||
continue
|
||||
|
||||
# 5. LLM
|
||||
response = self.extractor.extract(images_b64, prompt)
|
||||
raw_text = response.get("raw", "")
|
||||
|
||||
# 6. Parseo y Validación
|
||||
parsed = self._parse_json_response(raw_text)
|
||||
|
||||
# Guardar resultado cabecera
|
||||
result_id = self.db.add_result(run_id, current_radicado, file_path, "PROCESSED", raw_text)
|
||||
|
||||
# Guardar detalles y calcular match global
|
||||
global_match = True
|
||||
if not parsed:
|
||||
global_match = False
|
||||
# Guardar error de parseo?
|
||||
else:
|
||||
for pattern in patterns:
|
||||
key = pattern["key"]
|
||||
pat_mode = pattern.get("mode", "key")
|
||||
expected_val = pattern.get("value", "").lower().strip()
|
||||
|
||||
# Buscar en respuesta
|
||||
# Normalizamos claves para busqueda insensible a mayusculas
|
||||
found_item = None
|
||||
for k, v in parsed.items():
|
||||
if k.lower() == key.lower():
|
||||
found_item = v
|
||||
break
|
||||
|
||||
exists = False
|
||||
found_val = None
|
||||
|
||||
if found_item and isinstance(found_item, dict):
|
||||
exists = bool(found_item.get("exists", False))
|
||||
found_val = str(found_item.get("value", "") or "")
|
||||
|
||||
# Regla
|
||||
rule_ok = False
|
||||
if pat_mode == "key":
|
||||
rule_ok = exists
|
||||
elif pat_mode == "key+value":
|
||||
# Comparacion laxa
|
||||
rule_ok = exists and (expected_val in found_val.lower())
|
||||
|
||||
if not rule_ok:
|
||||
global_match = False
|
||||
|
||||
self.db.add_match(result_id, key, expected_val if pat_mode=="key+value" else None, found_val, exists)
|
||||
|
||||
# Actualizar status final
|
||||
final_status = "MATCH" if global_match else "NO_MATCH"
|
||||
self._update_result_status(result_id, final_status)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error procesando {file_path}")
|
||||
self.db.add_result(run_id, current_radicado, file_path, f"ERROR: {str(e)}", "")
|
||||
|
||||
return run_id
|
||||
|
||||
def _identify_files(self, rad_path: str, target_info: Dict[str, Any], mode: str = "radicados") -> List[str]:
|
||||
"""Devuelve lista de rutas de archivos a procesar."""
|
||||
if not os.path.exists(rad_path):
|
||||
return []
|
||||
|
||||
# Si mode es general, rad_path es la raiz y buscamos archivos directos.
|
||||
# Si mode es radicados, rad_path es la subcarpeta del radicado.
|
||||
# La logica es la misma: listar archivos en el directorio dado.
|
||||
|
||||
all_files = []
|
||||
try:
|
||||
for f in os.listdir(rad_path):
|
||||
full_path = os.path.join(rad_path, f)
|
||||
if os.path.isfile(full_path) and f.lower().endswith(('.pdf', '.tif', '.tiff')):
|
||||
all_files.append(full_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not target_info.get("enabled"):
|
||||
return all_files
|
||||
|
||||
pattern = target_info.get("pattern", "*").lower()
|
||||
filtered = [f for f in all_files if fnmatch.fnmatch(os.path.basename(f).lower(), pattern)]
|
||||
return filtered
|
||||
|
||||
def _get_images(self, file_path: str, page_range: Tuple[bool, int, int]) -> List[str]:
|
||||
"""Convierte archivo a imagenes, aplicando rango si es necesario."""
|
||||
enabled, start, end = page_range
|
||||
|
||||
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":
|
||||
return pdf_pages_to_images_bs64(file_path, s, e, dpi=300)
|
||||
elif ext in (".tif", ".tiff"):
|
||||
return document_to_images_bs64(file_path, tiff_page_range=(s, e), dpi=300)
|
||||
|
||||
return document_to_images_bs64(file_path, dpi=300)
|
||||
|
||||
def _build_prompt(self, keys: List[str]) -> str:
|
||||
keys_str = ", ".join(keys)
|
||||
return f"""Responde con true o false si tiene los siguientes campos, si existe extrae cuales son.
|
||||
Responde ÚNICAMENTE en JSON con el siguiente formato, sin bloques de código markdown:
|
||||
{{
|
||||
"nombre_campo": {{
|
||||
"exists": true,
|
||||
"value": "valor extraído"
|
||||
}},
|
||||
...
|
||||
}}
|
||||
|
||||
Campos a buscar: {keys_str}"""
|
||||
|
||||
def _parse_json_response(self, text: str) -> Dict[str, Any]:
|
||||
try:
|
||||
# Limpiar bloques markdown si el modelo los pone
|
||||
clean = text.replace("```json", "").replace("```", "").strip()
|
||||
return json.loads(clean)
|
||||
except Exception:
|
||||
# Intento de rescate simple con regex si falla json puro
|
||||
# Por ahora retornamos vacio para indicar fallo
|
||||
return {}
|
||||
|
||||
def _update_result_status(self, result_id: int, status: str):
|
||||
self.db.update_result_status(result_id, status)
|
||||
@@ -1,107 +0,0 @@
|
||||
import requests
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import getpass
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TrackingManager:
|
||||
"""Gestiona la comunicación con el servidor de seguimiento web."""
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8000"):
|
||||
self.base_url = base_url
|
||||
self.current_batch_id: Optional[str] = None
|
||||
self.is_enabled = True
|
||||
self._session = requests.Session() # Usar sesión para reutilizar conexiones
|
||||
|
||||
def start_batch(self, root_path: str, total_radicados: int, config: Optional[Dict] = None,
|
||||
csv_output_path: Optional[str] = None):
|
||||
"""Notifica el inicio de un nuevo lote al servidor."""
|
||||
if not self.is_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"root_path": root_path,
|
||||
"total_radicados": total_radicados,
|
||||
"csv_output_path": csv_output_path,
|
||||
"hostname": socket.gethostname(),
|
||||
"username": getpass.getuser(),
|
||||
"config": config or {}
|
||||
}
|
||||
response = self._session.post(f"{self.base_url}/batches", json=payload, timeout=5)
|
||||
if response.status_code == 200:
|
||||
self.current_batch_id = response.json().get("id")
|
||||
logger.info(f"Tracking iniciado: Batch ID {self.current_batch_id}")
|
||||
else:
|
||||
logger.warning(f"Error al iniciar tracking: {response.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error de conexión con el servidor de tracking: {e}")
|
||||
|
||||
def update_progress(self, current_index: int, success_count: int, error_count: int,
|
||||
total_tokens: int, total_cost_usd: float, status: str = "processing"):
|
||||
"""Actualiza el progreso del lote actual."""
|
||||
if not self.is_enabled or not self.current_batch_id:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"current_radicado_index": current_index,
|
||||
"success_count": success_count,
|
||||
"error_count": error_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_cost_usd": total_cost_usd,
|
||||
"status": status
|
||||
}
|
||||
self._session.patch(f"{self.base_url}/batches/{self.current_batch_id}", json=payload, timeout=1)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error al actualizar tracking: {e}")
|
||||
|
||||
def log(self, message: str, level: str = "info", radicado: str = None):
|
||||
"""Envía una entrada de log al servidor de tracking."""
|
||||
if not self.is_enabled or not self.current_batch_id:
|
||||
return
|
||||
try:
|
||||
payload = {"message": message, "level": level, "radicado": radicado}
|
||||
self._session.post(
|
||||
f"{self.base_url}/batches/{self.current_batch_id}/logs",
|
||||
json=payload, timeout=1
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error al enviar log a tracking: {e}")
|
||||
|
||||
def report_phase(self, phase: str, radicado: str = None):
|
||||
"""Reporta la fase actual de procesamiento (extract, normalize, csv, completed)."""
|
||||
if not self.is_enabled or not self.current_batch_id:
|
||||
return
|
||||
try:
|
||||
payload = {"current_phase": phase, "current_radicado": radicado}
|
||||
self._session.patch(f"{self.base_url}/batches/{self.current_batch_id}", json=payload, timeout=1)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error al reportar fase a tracking: {e}")
|
||||
|
||||
def report_error(self, error_message: str):
|
||||
"""Reporta un error crítico al servidor."""
|
||||
if not self.is_enabled or not self.current_batch_id:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = {"last_error": error_message}
|
||||
self._session.patch(f"{self.base_url}/batches/{self.current_batch_id}", json=payload, timeout=1)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error al reportar error de tracking: {e}")
|
||||
|
||||
def complete_batch(self, success_count: int, error_count: int, total_tokens: int, total_cost_usd: float):
|
||||
"""Notifica la finalización exitosa del lote."""
|
||||
self.update_progress(
|
||||
current_index=-1, # Indica finalización si se desea
|
||||
success_count=success_count,
|
||||
error_count=error_count,
|
||||
total_tokens=total_tokens,
|
||||
total_cost_usd=total_cost_usd,
|
||||
status="completed"
|
||||
)
|
||||
self.current_batch_id = None
|
||||
@@ -1,97 +0,0 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Build script para IPSExtractor
|
||||
REM Genera el ejecutable con PyInstaller y opcionalmente el instalador con
|
||||
REM Inno Setup.
|
||||
REM
|
||||
REM Uso:
|
||||
REM build_installer.bat (solo PyInstaller)
|
||||
REM build_installer.bat --inno (PyInstaller + Inno Setup)
|
||||
REM ============================================================================
|
||||
|
||||
setlocal
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
cd /d "%ROOT%"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo IPSExtractor - Build
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM -- Verificar Python ---
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python no encontrado en PATH.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM -- Verificar PyInstaller ---
|
||||
python -c "import PyInstaller" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Instalando PyInstaller...
|
||||
pip install pyinstaller
|
||||
)
|
||||
|
||||
REM -- Limpiar builds anteriores ---
|
||||
echo Limpiando builds anteriores...
|
||||
if exist "build" rmdir /s /q "build"
|
||||
if exist "dist\IPSExtractor" rmdir /s /q "dist\IPSExtractor"
|
||||
|
||||
REM -- Ejecutar PyInstaller ---
|
||||
echo.
|
||||
echo Ejecutando PyInstaller...
|
||||
echo.
|
||||
pyinstaller ipsextractor.spec --noconfirm
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: PyInstaller fallo. Revise los errores arriba.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Build exitoso!
|
||||
echo Output: dist\IPSExtractor\
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM -- Inno Setup (opcional) ---
|
||||
if /i "%~1"=="--inno" (
|
||||
echo Buscando Inno Setup...
|
||||
|
||||
set "ISCC="
|
||||
if exist "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" (
|
||||
set "ISCC=C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
if exist "C:\Program Files\Inno Setup 6\ISCC.exe" (
|
||||
set "ISCC=C:\Program Files\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
|
||||
if not defined ISCC (
|
||||
echo AVISO: Inno Setup no encontrado. Instale Inno Setup 6 para generar el instalador.
|
||||
echo Descargue en: https://jrsoftware.org/isdl.php
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo Generando instalador con Inno Setup...
|
||||
"%ISCC%" "%ROOT%installer.iss"
|
||||
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Inno Setup fallo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Instalador generado en: dist\
|
||||
echo ========================================
|
||||
)
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,77 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
container_name: ipsextractor-db
|
||||
environment:
|
||||
POSTGRES_DB: ipsextractor
|
||||
POSTGRES_USER: ipsextractor
|
||||
POSTGRES_PASSWORD: ipsextractor
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ipsextractor_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ipsextractor -d ipsextractor"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
extraction:
|
||||
build:
|
||||
context: ./services/extraction
|
||||
dockerfile: Dockerfile
|
||||
container_name: ipsextractor-extraction
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor
|
||||
EXTRACTOR_PROVIDER: ${EXTRACTOR_PROVIDER:-}
|
||||
EXTRACTOR_BASE_URL: ${EXTRACTOR_BASE_URL:-}
|
||||
EXTRACTOR_API_KEY: ${EXTRACTOR_API_KEY:-}
|
||||
EXTRACTOR_MODEL: ${EXTRACTOR_MODEL:-}
|
||||
NORMALIZER_PROVIDER: ${NORMALIZER_PROVIDER:-}
|
||||
NORMALIZER_BASE_URL: ${NORMALIZER_BASE_URL:-}
|
||||
NORMALIZER_API_KEY: ${NORMALIZER_API_KEY:-}
|
||||
NORMALIZER_MODEL: ${NORMALIZER_MODEL:-}
|
||||
NORMALIZER_TIMEOUT_S: ${NORMALIZER_TIMEOUT_S:-0}
|
||||
EXTRACTOR_TIMEOUT_S: ${EXTRACTOR_TIMEOUT_S:-120}
|
||||
PDF_DPI: ${PDF_DPI:-600}
|
||||
PDF_THREAD_COUNT: ${PDF_THREAD_COUNT:-10}
|
||||
POPPLER_PATH: ${POPPLER_PATH:-}
|
||||
CONCURRENCY_LIMIT: ${CONCURRENCY_LIMIT:-4}
|
||||
ALLOWED_DOCUMENT_TYPES: ${ALLOWED_DOCUMENT_TYPES:-CC,TI,CE,RC,PA,PPT,PEP,EPT,NUIP}
|
||||
ports:
|
||||
- "8001:8001"
|
||||
volumes:
|
||||
- ${IPS_DOCUMENT_ROOT:-./data}:/data
|
||||
|
||||
cleaning:
|
||||
build:
|
||||
context: ./services/cleaning
|
||||
dockerfile: Dockerfile
|
||||
container_name: ipsextractor-cleaning
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor
|
||||
ports:
|
||||
- "8002:8002"
|
||||
|
||||
api_gateway:
|
||||
build:
|
||||
context: ./services/api_gateway
|
||||
dockerfile: Dockerfile
|
||||
container_name: ipsextractor-api
|
||||
depends_on:
|
||||
- extraction
|
||||
- cleaning
|
||||
environment:
|
||||
EXTRACTION_URL: http://extraction:8001
|
||||
CLEANING_URL: http://cleaning:8002
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
ipsextractor_db:
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Módulo de GUI."""
|
||||
from gui.view import InvoiceAppView
|
||||
from gui.styles import AppStyles
|
||||
|
||||
__all__ = ["InvoiceAppView", "AppStyles"]
|
||||
-1410
File diff suppressed because it is too large
Load Diff
@@ -1,386 +0,0 @@
|
||||
"""Diálogo de selección de campos basado en `customtkinter`.
|
||||
|
||||
Permite buscar, seleccionar/deseleccionar y revisar una muestra de la primera
|
||||
factura para facilitar la elección de paths del schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Callable, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
import threading
|
||||
import tkinter.messagebox as mb
|
||||
from application.app_prefs import get_allowed_document_types
|
||||
|
||||
DOCUMENT_TYPE_HINT_ALIASES = {
|
||||
"CC", "TI", "CE", "RC", "PA", "PPT", "PEP", "EPT", "NUIP",
|
||||
"PT", "PAS", "PASS",
|
||||
}
|
||||
|
||||
|
||||
class FieldSelectorDialog(ctk.CTkToplevel):
|
||||
"""Ventana modal para elegir campos del schema a extraer."""
|
||||
def __init__(self, master, schema: Dict[str, str], sample: Optional[dict] = None, initial_selected: Optional[List[str]] = None):
|
||||
"""Crea el diálogo con lista scrolleable y búsqueda.
|
||||
|
||||
Args:
|
||||
master: Ventana padre.
|
||||
schema: Mapa path->tipo (ayuda visual en etiqueta del checkbox).
|
||||
sample: JSON de muestra para vista previa.
|
||||
initial_selected: Paths seleccionados inicialmente.
|
||||
"""
|
||||
super().__init__(master)
|
||||
self.title("Seleccionar campos del schema")
|
||||
self.geometry("800x600")
|
||||
self.transient(master)
|
||||
self.grab_set()
|
||||
|
||||
self._schema = schema
|
||||
self._sample = sample
|
||||
self._on_submit: Optional[Callable[[List[str], Dict[str, str]], None]] = None
|
||||
self._on_reprocess: Optional[Callable[[], tuple[Dict[str, str], dict]]] = None
|
||||
|
||||
self.search_entry = ctk.CTkEntry(self, placeholder_text="Buscar campo...")
|
||||
self.search_entry.grid(row=0, column=0, columnspan=3, sticky="we", padx=10, pady=(10, 5))
|
||||
self.search_entry.bind("<KeyRelease>", self._on_search)
|
||||
|
||||
self.scroll = ctk.CTkScrollableFrame(self)
|
||||
self.scroll.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=10, pady=5)
|
||||
|
||||
self.select_all_btn = ctk.CTkButton(self, text="Seleccionar todo", command=self._select_all)
|
||||
self.select_all_btn.grid(row=2, column=0, padx=10, pady=10, sticky="w")
|
||||
self.clear_btn = ctk.CTkButton(self, text="Limpiar", command=self._clear_all)
|
||||
self.clear_btn.grid(row=2, column=1, padx=10, pady=10, sticky="w")
|
||||
self._right_btns = ctk.CTkFrame(self)
|
||||
self._right_btns.grid(row=2, column=2, padx=10, pady=10, sticky="e")
|
||||
self.sample_btn = ctk.CTkButton(self._right_btns, text="Ver muestra", command=self._show_sample)
|
||||
self.sample_btn.pack(side="left", padx=(0, 8))
|
||||
self.reprocess_btn = ctk.CTkButton(self._right_btns, text="Reprocesar", command=self._reprocess)
|
||||
self.reprocess_btn.pack(side="left")
|
||||
|
||||
self.cancel_btn = ctk.CTkButton(self, text="Cancelar", command=self._cancel)
|
||||
self.cancel_btn.grid(row=3, column=0, padx=10, pady=(0, 10), sticky="w")
|
||||
self.ok_btn = ctk.CTkButton(self, text="Aceptar", command=self._accept)
|
||||
self.ok_btn.grid(row=3, column=2, padx=10, pady=(0, 10), sticky="e")
|
||||
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_columnconfigure(1, weight=0)
|
||||
self.grid_columnconfigure(2, weight=1)
|
||||
self.grid_rowconfigure(1, weight=1)
|
||||
|
||||
self._vars: Dict[str, ctk.BooleanVar] = {}
|
||||
self._rows: Dict[str, ctk.CTkFrame] = {}
|
||||
self._alias_entries: Dict[str, ctk.CTkEntry] = {}
|
||||
self._group_labels: Dict[str, ctk.CTkLabel] = {}
|
||||
self._group_paths: Dict[str, list[str]] = {}
|
||||
|
||||
initial_set = set(initial_selected or [])
|
||||
# Construir grupos por prefijo (Tipo) cuando el path contiene '/'
|
||||
grouped: Dict[str, list[tuple[str, str]]] = {}
|
||||
for path, typ in sorted(self._schema.items(), key=lambda x: x[0]):
|
||||
if '/' in path:
|
||||
group, sub = path.split('/', 1)
|
||||
else:
|
||||
group, sub = "General", path
|
||||
grouped.setdefault(group, []).append((path, typ))
|
||||
# Crear filas agrupadas
|
||||
rows_container = self.scroll
|
||||
for group_name, items in grouped.items():
|
||||
# Encabezado del grupo
|
||||
glabel = ctk.CTkLabel(rows_container, text=group_name, anchor="w")
|
||||
glabel.pack(anchor="w", fill="x", padx=8, pady=(6, 2))
|
||||
self._group_labels[group_name] = glabel
|
||||
self._group_paths[group_name] = []
|
||||
# Filas del grupo
|
||||
for path, typ in items:
|
||||
var = ctk.BooleanVar(value=(path in initial_set))
|
||||
row = ctk.CTkFrame(rows_container)
|
||||
row.pack(anchor="w", fill="x", padx=8, pady=2)
|
||||
cb = ctk.CTkCheckBox(row, text=f"{path} ({typ})", variable=var)
|
||||
cb.grid(row=0, column=0, sticky="w")
|
||||
alias_entry = ctk.CTkEntry(row, width=260, placeholder_text="Nombre en CSV")
|
||||
try:
|
||||
alias_entry.insert(0, path)
|
||||
except Exception:
|
||||
pass
|
||||
alias_entry.grid(row=0, column=1, padx=(12, 0))
|
||||
self._add_document_type_hint(row, path)
|
||||
try:
|
||||
alias_entry.bind("<KeyRelease>", lambda _e: self._validate_aliases())
|
||||
except Exception:
|
||||
pass
|
||||
def _toggle(ent: ctk.CTkEntry, v: ctk.BooleanVar = var, p: str = path):
|
||||
try:
|
||||
if p == 'radicado':
|
||||
ent.grid_remove()
|
||||
else:
|
||||
if v.get():
|
||||
ent.grid(row=0, column=1, padx=(12, 0))
|
||||
else:
|
||||
ent.grid_remove()
|
||||
except Exception:
|
||||
pass
|
||||
self._validate_aliases()
|
||||
_toggle(alias_entry)
|
||||
cb.configure(command=lambda e=alias_entry, v=var, p=path: _toggle(e, v, p))
|
||||
self._vars[path] = var
|
||||
self._rows[path] = row
|
||||
self._alias_entries[path] = alias_entry
|
||||
self._group_paths[group_name].append(path)
|
||||
self._validate_aliases()
|
||||
|
||||
def set_on_submit(self, callback: Callable[[List[str], Dict[str, str]], None]) -> None:
|
||||
"""Registra el callback a invocar con (selected_fields, alias_map)."""
|
||||
self._on_submit = callback
|
||||
|
||||
def set_on_reprocess(self, callback: Callable[[], tuple[Dict[str, str], dict]]) -> None:
|
||||
"""Registra el callback a invocar para reprocesar y actualizar (schema, sample)."""
|
||||
self._on_reprocess = callback
|
||||
|
||||
def _on_search(self, _event=None):
|
||||
"""Filtra filas visibles por coincidencia de texto en el path."""
|
||||
query = self.search_entry.get().strip().lower()
|
||||
|
||||
# Lista ordenada de grupos según se crearon
|
||||
ordered_groups = list(self._group_labels.keys())
|
||||
|
||||
for group_name in ordered_groups:
|
||||
paths = self._group_paths.get(group_name, [])
|
||||
# Determinar qué filas de este grupo coinciden
|
||||
visible_paths = []
|
||||
for p in paths:
|
||||
if query in p.lower():
|
||||
visible_paths.append(p)
|
||||
|
||||
# Si hay filas visibles, mostrar grupo y esas filas; si no, ocultar todo el grupo
|
||||
lbl = self._group_labels.get(group_name)
|
||||
if visible_paths:
|
||||
if lbl:
|
||||
lbl.pack(anchor="w", fill="x", padx=8, pady=(6, 2))
|
||||
for p in paths:
|
||||
row = self._rows.get(p)
|
||||
if not row: continue
|
||||
if p in visible_paths:
|
||||
row.pack(anchor="w", fill="x", padx=8, pady=2)
|
||||
else:
|
||||
row.pack_forget()
|
||||
else:
|
||||
if lbl:
|
||||
lbl.pack_forget()
|
||||
for p in paths:
|
||||
row = self._rows.get(p)
|
||||
if row:
|
||||
row.pack_forget()
|
||||
|
||||
def _select_all(self):
|
||||
"""Marca todos los campos y muestra las entradas de alias (excepto radicado)."""
|
||||
for path, var in self._vars.items():
|
||||
var.set(True)
|
||||
try:
|
||||
ent = self._alias_entries.get(path)
|
||||
if ent is not None:
|
||||
if path == 'radicado':
|
||||
ent.grid_remove()
|
||||
else:
|
||||
ent.grid(row=0, column=1, padx=(12, 0))
|
||||
except Exception:
|
||||
pass
|
||||
self._validate_aliases()
|
||||
|
||||
def _clear_all(self):
|
||||
"""Desmarca todos los campos y oculta entradas de alias."""
|
||||
for path, var in self._vars.items():
|
||||
var.set(False)
|
||||
try:
|
||||
ent = self._alias_entries.get(path)
|
||||
if ent is not None:
|
||||
ent.grid_remove()
|
||||
except Exception:
|
||||
pass
|
||||
self._validate_aliases()
|
||||
|
||||
def _show_sample(self):
|
||||
"""Muestra la muestra JSON en un toplevel de solo lectura (si existe)."""
|
||||
if self._sample is None:
|
||||
return
|
||||
win = ctk.CTkToplevel(self)
|
||||
win.title("Muestra de la primera factura")
|
||||
win.geometry("700x500")
|
||||
win.transient(self)
|
||||
win.lift()
|
||||
try:
|
||||
win.attributes("-topmost", True)
|
||||
# mantener al frente un instante y luego permitir comportamiento normal
|
||||
win.after(300, lambda: win.attributes("-topmost", False))
|
||||
except Exception:
|
||||
pass
|
||||
win.focus_force()
|
||||
txt = ctk.CTkTextbox(win)
|
||||
txt.pack(expand=True, fill="both", padx=10, pady=10)
|
||||
txt.insert("end", json.dumps(self._sample, ensure_ascii=False, indent=2))
|
||||
txt.configure(state="disabled")
|
||||
|
||||
def _accept(self):
|
||||
"""Construye selección y alias y cierra devolviendo datos al callback."""
|
||||
selected = [p for p, v in self._vars.items() if v.get()]
|
||||
alias_map: Dict[str, str] = {}
|
||||
for p in selected:
|
||||
ent = self._alias_entries.get(p)
|
||||
alias = None
|
||||
try:
|
||||
alias = (ent.get().strip() if ent is not None else "")
|
||||
except Exception:
|
||||
alias = ""
|
||||
if not alias:
|
||||
alias = p
|
||||
# 'radicado' permanece fijo si aparece
|
||||
if p == 'radicado':
|
||||
alias = 'radicado'
|
||||
alias_map[p] = alias
|
||||
if self._on_submit:
|
||||
self._on_submit(selected, alias_map)
|
||||
self.destroy()
|
||||
|
||||
def _cancel(self):
|
||||
"""Cancela la selección (devuelve listas vacías) y cierra el diálogo."""
|
||||
if self._on_submit:
|
||||
self._on_submit([], {})
|
||||
self.destroy()
|
||||
|
||||
def _validate_aliases(self):
|
||||
"""Valida alias. (Validación de duplicados eliminada para permitir fusión de columnas)."""
|
||||
# Siempre permitir aceptar, ya que los duplicados se fusionarán en el backend.
|
||||
try:
|
||||
self.ok_btn.configure(state="normal")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --- Reprocesar (reextraer primer documento y refrescar lista) ---
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
"""Habilita/deshabilita controles y cambia texto del botón Reprocesar."""
|
||||
try:
|
||||
state = "disabled" if busy else "normal"
|
||||
self.select_all_btn.configure(state=state)
|
||||
self.clear_btn.configure(state=state)
|
||||
self.sample_btn.configure(state=state)
|
||||
self.ok_btn.configure(state=state)
|
||||
self.cancel_btn.configure(state=state)
|
||||
self.search_entry.configure(state=state)
|
||||
self.reprocess_btn.configure(text=("Reprocesando…" if busy else "Reprocesar"), state=("disabled" if busy else "normal"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _reprocess(self):
|
||||
"""Ejecuta reproceso en hilo de fondo y actualiza schema/muestra preservando selección y alias existentes."""
|
||||
if self._on_reprocess is None:
|
||||
return
|
||||
self._set_busy(True)
|
||||
|
||||
def worker():
|
||||
try:
|
||||
new_schema, new_sample = self._on_reprocess()
|
||||
def apply_updates():
|
||||
self._apply_new_schema_sample(new_schema, new_sample)
|
||||
self._set_busy(False)
|
||||
self.after(0, apply_updates)
|
||||
except Exception as e:
|
||||
def on_err():
|
||||
try:
|
||||
mb.showerror("Error de reproceso", f"No se pudo reprocesar: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
self._set_busy(False)
|
||||
self.after(0, on_err)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _apply_new_schema_sample(self, new_schema: Dict[str, str], new_sample: dict) -> None:
|
||||
"""Reconstruye la lista de campos preservando selección y alias cuando sea posible."""
|
||||
# Guardar selección y alias actuales
|
||||
prev_selected = {p for p, v in self._vars.items() if v.get()}
|
||||
prev_alias: Dict[str, str] = {}
|
||||
for p, ent in self._alias_entries.items():
|
||||
try:
|
||||
val = (ent.get().strip() if ent is not None else "") or p
|
||||
except Exception:
|
||||
val = p
|
||||
if p == 'radicado':
|
||||
val = 'radicado'
|
||||
prev_alias[p] = val
|
||||
|
||||
# Limpiar filas actuales
|
||||
try:
|
||||
for _, frame in self._rows.items():
|
||||
frame.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._vars.clear()
|
||||
self._rows.clear()
|
||||
self._alias_entries.clear()
|
||||
|
||||
# Asignar nuevos datos
|
||||
self._schema = new_schema or {}
|
||||
self._sample = new_sample
|
||||
|
||||
rows_container = self.scroll
|
||||
for path in sorted(self._schema.keys()):
|
||||
var = ctk.BooleanVar(value=(path in prev_selected))
|
||||
row = ctk.CTkFrame(rows_container)
|
||||
row.pack(anchor="w", fill="x", padx=8, pady=2)
|
||||
cb = ctk.CTkCheckBox(row, text=f"{path} ({self._schema[path]})", variable=var)
|
||||
cb.grid(row=0, column=0, sticky="w")
|
||||
alias_entry = ctk.CTkEntry(row, width=260, placeholder_text="Nombre en CSV")
|
||||
# Establecer alias previo si existe, si no por defecto al path
|
||||
alias_value = prev_alias.get(path, path)
|
||||
try:
|
||||
alias_entry.insert(0, alias_value)
|
||||
except Exception:
|
||||
pass
|
||||
alias_entry.grid(row=0, column=1, padx=(12, 0))
|
||||
self._add_document_type_hint(row, path)
|
||||
try:
|
||||
alias_entry.bind("<KeyRelease>", lambda _e: self._validate_aliases())
|
||||
except Exception:
|
||||
pass
|
||||
def _toggle(ent: ctk.CTkEntry, v: ctk.BooleanVar = var, p: str = path):
|
||||
try:
|
||||
if p == 'radicado':
|
||||
ent.grid_remove()
|
||||
else:
|
||||
if v.get():
|
||||
ent.grid(row=0, column=1, padx=(12, 0))
|
||||
else:
|
||||
ent.grid_remove()
|
||||
except Exception:
|
||||
pass
|
||||
self._validate_aliases()
|
||||
_toggle(alias_entry)
|
||||
cb.configure(command=lambda e=alias_entry, v=var, p=path: _toggle(e, v, p))
|
||||
self._vars[path] = var
|
||||
self._rows[path] = row
|
||||
self._alias_entries[path] = alias_entry
|
||||
|
||||
# Revalidar y re-aplicar filtro de búsqueda actual
|
||||
self._validate_aliases()
|
||||
try:
|
||||
self._on_search()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _add_document_type_hint(self, row: ctk.CTkFrame, path: str) -> None:
|
||||
"""Muestra una ayuda breve bajo claves tipo CC, TI, CE, PAS, etc."""
|
||||
key_tail = str(path or "").split("/")[-1].strip().upper()
|
||||
valid_hint_keys = set(get_allowed_document_types()) | DOCUMENT_TYPE_HINT_ALIASES
|
||||
if key_tail not in valid_hint_keys:
|
||||
return
|
||||
|
||||
hint = ctk.CTkLabel(
|
||||
row,
|
||||
text="Si a este campo le pones el alias 'documento_forzate', se guardará con la sigla exacta detectada, por ejemplo 'CC 123456789' o 'EPT 123456789'.",
|
||||
anchor="w",
|
||||
justify="left",
|
||||
)
|
||||
hint.grid(row=1, column=0, columnspan=2, sticky="w", padx=(28, 0), pady=(2, 0))
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Configuración centralizada de estilos, temas y fuentes para la GUI."""
|
||||
import customtkinter as ctk
|
||||
|
||||
|
||||
class AppStyles:
|
||||
"""Clase con configuración de estilos y fuentes de la aplicación."""
|
||||
|
||||
@staticmethod
|
||||
def configure_theme():
|
||||
"""Configura el tema y apariencia general de la aplicación."""
|
||||
ctk.set_appearance_mode("System")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
@staticmethod
|
||||
def get_fonts():
|
||||
"""Devuelve un diccionario con las fuentes personalizadas."""
|
||||
return {
|
||||
"title": ctk.CTkFont(family="Roboto", size=22, weight="bold"),
|
||||
"section": ctk.CTkFont(family="Roboto", size=16, weight="bold"),
|
||||
"ui": ctk.CTkFont(family="Roboto", size=13),
|
||||
"mono": ctk.CTkFont(family="Consolas", size=12),
|
||||
"mono_small": ctk.CTkFont(family="Consolas", size=10),
|
||||
"italic_small": ctk.CTkFont(size=12, slant="italic"),
|
||||
"bold_large": ctk.CTkFont(size=16, weight="bold"),
|
||||
"button_large": ctk.CTkFont(size=14, weight="bold"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_colors():
|
||||
"""Devuelve un diccionario con los colores personalizados."""
|
||||
return {
|
||||
"primary_button": ("#106BA3", "#106BA3"),
|
||||
"primary_hover": ("#0D5582", "#0D5582"),
|
||||
"secondary_button": ("#2E86C1", "#2E86C1"),
|
||||
"secondary_hover": ("#21618C", "#21618C"),
|
||||
"success": ("#27AE60", "#27AE60"),
|
||||
"success_hover": ("#1E8449", "#1E8449"),
|
||||
"warning": ("#D35400", "#D35400"),
|
||||
"warning_hover": ("#A04000", "#A04000"),
|
||||
"danger": ("#C0392B", "#C0392B"),
|
||||
"danger_hover": ("#A93226", "#A93226"),
|
||||
"gray_light": "gray60",
|
||||
"gray_dark": "gray70",
|
||||
"text_muted": ("gray50", "gray70"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_spacing():
|
||||
"""Devuelve un diccionario con espaciados y padding estándar."""
|
||||
return {
|
||||
"padding_small": 2,
|
||||
"padding_medium": 5,
|
||||
"padding_large": 10,
|
||||
"padding_section": 15,
|
||||
"margin_small": (0, 2),
|
||||
"margin_medium": (5, 5),
|
||||
"margin_large": (10, 15),
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Módulo de pestañas de la GUI."""
|
||||
from gui.tabs.settings_tab import SettingsTab
|
||||
from gui.tabs.results_tab import ResultsTab
|
||||
from gui.tabs.main_tab import MainProcessingTab
|
||||
from gui.tabs.preclass_tab import PreclassificationTab
|
||||
from gui.tabs.metrics_tab import MetricsTab
|
||||
from gui.tabs.postprocess_tab import PostProcessTab
|
||||
from gui.tabs.cleaning_tab import CleaningTab
|
||||
|
||||
__all__ = [
|
||||
"SettingsTab",
|
||||
"ResultsTab",
|
||||
"MainProcessingTab",
|
||||
"PreclassificationTab",
|
||||
"MetricsTab",
|
||||
"PostProcessTab",
|
||||
"CleaningTab",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,574 +0,0 @@
|
||||
"""Pestaña Procesar Documentos de la aplicación GUI."""
|
||||
import customtkinter as ctk
|
||||
from gui.styles import AppStyles
|
||||
from application.app_prefs import get_allowed_document_types
|
||||
|
||||
|
||||
class MainProcessingTab:
|
||||
"""Encapsula la UI de la pestaña Procesar Documentos."""
|
||||
|
||||
def __init__(self, parent_tab):
|
||||
"""Inicializa la pestaña de Procesar Documentos.
|
||||
|
||||
Args:
|
||||
parent_tab: El CTkFrame que representa la pestaña.
|
||||
"""
|
||||
self.tab = parent_tab
|
||||
self.fonts = AppStyles.get_fonts()
|
||||
self.colors = AppStyles.get_colors()
|
||||
self.spacing = AppStyles.get_spacing()
|
||||
|
||||
# Variables internas
|
||||
self._doc_type_rows = []
|
||||
|
||||
self._setup_layout()
|
||||
self._create_paths_section()
|
||||
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."""
|
||||
self.tab.grid_columnconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(0, weight=1)
|
||||
|
||||
self.scroll_container = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
|
||||
self.scroll_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
self.scroll_container.grid_columnconfigure(0, weight=1)
|
||||
|
||||
def _create_paths_section(self):
|
||||
"""Crea la sección de rutas y archivos."""
|
||||
self.frame_paths = ctk.CTkFrame(self.scroll_container)
|
||||
self.frame_paths.grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 2))
|
||||
self.frame_paths.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Título
|
||||
lbl_paths = ctk.CTkLabel(
|
||||
self.frame_paths,
|
||||
text="Rutas y Archivos",
|
||||
font=self.fonts["section"]
|
||||
)
|
||||
lbl_paths.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(5, 2))
|
||||
|
||||
# Carpeta Raíz
|
||||
self.root_label = ctk.CTkLabel(self.frame_paths, text="Carpeta Raíz:", font=self.fonts["ui"])
|
||||
self.root_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
|
||||
|
||||
self.root_entry = ctk.CTkEntry(
|
||||
self.frame_paths,
|
||||
placeholder_text="Selecciona la carpeta con los documentos...",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.root_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
|
||||
|
||||
self.root_button = ctk.CTkButton(self.frame_paths, text="Explorar...", font=self.fonts["ui"], width=100)
|
||||
self.root_button.grid(row=1, column=2, padx=15, pady=2)
|
||||
|
||||
# CSV Salida
|
||||
self.csv_label = ctk.CTkLabel(self.frame_paths, text="Archivo CSV:", font=self.fonts["ui"])
|
||||
self.csv_label.grid(row=2, column=0, padx=15, pady=2, sticky="w")
|
||||
|
||||
self.csv_entry = ctk.CTkEntry(
|
||||
self.frame_paths,
|
||||
placeholder_text="Ruta para guardar el reporte CSV...",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.csv_entry.grid(row=2, column=1, padx=5, pady=2, sticky="ew")
|
||||
|
||||
self.csv_button = ctk.CTkButton(
|
||||
self.frame_paths,
|
||||
text="Guardar en...",
|
||||
font=self.fonts["ui"],
|
||||
width=100,
|
||||
fg_color="transparent",
|
||||
border_width=1
|
||||
)
|
||||
self.csv_button.grid(row=2, column=2, padx=15, pady=2)
|
||||
|
||||
# Selector de Modo (Radicados vs General)
|
||||
self.mode_var = ctk.StringVar(value="radicados")
|
||||
|
||||
lbl_mode = ctk.CTkLabel(self.frame_paths, text="Estructura:", font=self.fonts["ui"])
|
||||
lbl_mode.grid(row=3, column=0, padx=15, pady=2, sticky="w")
|
||||
|
||||
self.frame_radios_main = ctk.CTkFrame(self.frame_paths, fg_color="transparent")
|
||||
self.frame_radios_main.grid(row=3, column=1, columnspan=2, sticky="w", padx=5, pady=2)
|
||||
|
||||
self.radio_radicados_main = ctk.CTkRadioButton(
|
||||
self.frame_radios_main,
|
||||
text="Por Radicados (Subcarpetas)",
|
||||
variable=self.mode_var,
|
||||
value="radicados",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.radio_radicados_main.pack(side="left", padx=(0, 20))
|
||||
|
||||
self.radio_general_main = ctk.CTkRadioButton(
|
||||
self.frame_radios_main,
|
||||
text="Carpeta General (Archivos Planos)",
|
||||
variable=self.mode_var,
|
||||
value="general",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.radio_general_main.pack(side="left")
|
||||
|
||||
# Filtro por Radicados
|
||||
self.filter_enabled_var = ctk.BooleanVar(value=False)
|
||||
self.filter_check = ctk.CTkCheckBox(
|
||||
self.frame_paths,
|
||||
text="Procesar solo radicados seleccionados",
|
||||
variable=self.filter_enabled_var,
|
||||
font=self.fonts["ui"],
|
||||
command=self._on_filter_toggle
|
||||
)
|
||||
self.filter_check.grid(row=4, column=0, columnspan=3, padx=15, pady=(4, 0), sticky="w")
|
||||
|
||||
self.filter_frame = ctk.CTkFrame(self.frame_paths, fg_color="transparent")
|
||||
self.filter_frame.grid(row=5, column=0, columnspan=3, padx=15, pady=(2, 6), sticky="ew")
|
||||
self.filter_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
filter_hint = ctk.CTkLabel(
|
||||
self.filter_frame,
|
||||
text="Ingresa los radicados a procesar (uno por línea o separados por coma):",
|
||||
font=self.fonts["ui"],
|
||||
text_color=self.colors["text_muted"]
|
||||
)
|
||||
filter_hint.grid(row=0, column=0, sticky="w", pady=(0, 2))
|
||||
|
||||
self.filter_textbox = ctk.CTkTextbox(
|
||||
self.filter_frame,
|
||||
height=80,
|
||||
font=self.fonts["mono"],
|
||||
)
|
||||
self.filter_textbox.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
# Ocultar hasta que se active el checkbox
|
||||
self.filter_frame.grid_remove()
|
||||
|
||||
def _on_filter_toggle(self):
|
||||
"""Muestra u oculta el área de texto del filtro."""
|
||||
if self.filter_enabled_var.get():
|
||||
self.filter_frame.grid()
|
||||
else:
|
||||
self.filter_frame.grid_remove()
|
||||
|
||||
def _create_config_section(self):
|
||||
"""Crea la sección de configuración de tipos de documento y TIFF."""
|
||||
self.frame_config = ctk.CTkFrame(self.scroll_container)
|
||||
self.frame_config.grid(row=1, column=0, sticky="ew", padx=10, pady=2)
|
||||
self.frame_config.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Keywords oculto (legacy)
|
||||
self.kw_label = ctk.CTkLabel(self.frame_config, text="Keywords:")
|
||||
self.kw_entry = ctk.CTkEntry(self.frame_config)
|
||||
self.kw_entry.insert(0, "Factura_O_Documento_Equivalente,factura")
|
||||
|
||||
# Tipos de Documento
|
||||
self.doc_types_container = ctk.CTkFrame(self.frame_config, fg_color="transparent")
|
||||
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
|
||||
self.doc_types_container.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.doc_types_header = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
|
||||
self.doc_types_header.grid(row=0, column=0, sticky="ew", pady=(0, 2))
|
||||
self.doc_types_header.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.doc_types_label = ctk.CTkLabel(
|
||||
self.doc_types_header,
|
||||
text="Definición de Tipos de Documento",
|
||||
font=self.fonts["section"]
|
||||
)
|
||||
self.doc_types_label.grid(row=0, column=0, sticky="w")
|
||||
|
||||
allowed_types_text = ", ".join(get_allowed_document_types())
|
||||
self.doc_types_hint = ctk.CTkLabel(
|
||||
self.doc_types_header,
|
||||
text=f"Tipos válidos para documento_forzate: {allowed_types_text}. NIT no aplica.",
|
||||
font=self.fonts["ui"],
|
||||
text_color=self.colors["text_muted"],
|
||||
)
|
||||
self.doc_types_hint.grid(row=1, column=0, sticky="w", pady=(2, 0))
|
||||
|
||||
self.add_doc_type_btn = ctk.CTkButton(
|
||||
self.doc_types_header,
|
||||
text="+ Añadir Tipo",
|
||||
font=self.fonts["ui"],
|
||||
width=120,
|
||||
height=28
|
||||
)
|
||||
self.add_doc_type_btn.grid(row=0, column=1, rowspan=2, sticky="e")
|
||||
self.add_doc_type_btn.configure(command=self._add_doc_type_row)
|
||||
|
||||
# Lista de Tipos
|
||||
self.doc_types_list = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
|
||||
self.doc_types_list.grid(row=1, column=0, sticky="ew", pady=(2, 0))
|
||||
self.doc_types_list.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Fila por defecto
|
||||
self._add_doc_type_row(name="Factura", keyword="Factura_O_Documento_Equivalente")
|
||||
|
||||
# Configuración Avanzada (TIFF Range)
|
||||
self.frame_advanced = ctk.CTkFrame(self.frame_config, fg_color="transparent")
|
||||
self.frame_advanced.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 2))
|
||||
self.frame_advanced.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.retry_empty_var = ctk.BooleanVar(value=False)
|
||||
self.retry_empty_check = ctk.CTkCheckBox(
|
||||
self.frame_advanced,
|
||||
text="Reintentar si la fila sale vacía (máx. 2 intentos)",
|
||||
variable=self.retry_empty_var,
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.retry_empty_check.grid(row=0, column=0, columnspan=4, padx=5, pady=(0, 6), sticky="w")
|
||||
|
||||
self.tiff_start_label = ctk.CTkLabel(
|
||||
self.frame_advanced,
|
||||
text="Pág. Inicio TIFF:",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.tiff_start_entry = ctk.CTkEntry(self.frame_advanced, width=60)
|
||||
self.tiff_start_entry.insert(0, "0")
|
||||
|
||||
self.tiff_end_label = ctk.CTkLabel(
|
||||
self.frame_advanced,
|
||||
text="Pág. Fin TIFF:",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.tiff_end_entry = ctk.CTkEntry(self.frame_advanced, width=60)
|
||||
self.tiff_end_entry.insert(0, "0")
|
||||
|
||||
# Ocultar inicialmente
|
||||
self.tiff_start_label.grid_forget()
|
||||
self.tiff_start_entry.grid_forget()
|
||||
self.tiff_end_label.grid_forget()
|
||||
self.tiff_end_entry.grid_forget()
|
||||
|
||||
# Campos legacy ocultos
|
||||
self.fields_entry = ctk.CTkEntry(self.tab)
|
||||
self.fields_label = ctk.CTkLabel(self.tab, text="")
|
||||
|
||||
def _create_process_button(self):
|
||||
"""Crea el botón de acción principal."""
|
||||
self.process_button = ctk.CTkButton(
|
||||
self.scroll_container,
|
||||
text="INICIAR PROCESAMIENTO",
|
||||
font=self.fonts["bold_large"],
|
||||
height=35,
|
||||
fg_color=self.colors["primary_button"],
|
||||
hover_color=self.colors["primary_hover"]
|
||||
)
|
||||
self.process_button.grid(row=2, column=0, padx=15, pady=(10, 15), sticky="ew")
|
||||
|
||||
def _create_output_section(self):
|
||||
"""Crea la sección de logs/salida."""
|
||||
self.log_frame = ctk.CTkFrame(self.scroll_container)
|
||||
self.log_frame.grid(row=3, column=0, sticky="ew", padx=10, pady=(0, 15))
|
||||
self.log_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl_logs = ctk.CTkLabel(
|
||||
self.log_frame,
|
||||
text="Registro de Actividad:",
|
||||
font=self.fonts["ui"],
|
||||
text_color=self.colors["text_muted"]
|
||||
)
|
||||
lbl_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0))
|
||||
|
||||
self.output_text = ctk.CTkTextbox(
|
||||
self.log_frame,
|
||||
font=self.fonts["mono"],
|
||||
activate_scrollbars=True,
|
||||
height=150
|
||||
)
|
||||
self.output_text.grid(row=1, column=0, padx=5, pady=5, sticky="ew")
|
||||
|
||||
def _add_doc_type_row(self, name: str = "", keyword: str = "") -> None:
|
||||
"""Añade una fila de tipo de documento."""
|
||||
row = ctk.CTkFrame(self.doc_types_list, fg_color="transparent")
|
||||
row.pack(fill="x", pady=2)
|
||||
|
||||
grid_inner = ctk.CTkFrame(row, fg_color="transparent")
|
||||
grid_inner.pack(fill="x", padx=5, pady=2)
|
||||
grid_inner.grid_columnconfigure(0, weight=1)
|
||||
grid_inner.grid_columnconfigure(1, weight=1)
|
||||
|
||||
name_entry = ctk.CTkEntry(
|
||||
grid_inner,
|
||||
placeholder_text="Nombre Lógico",
|
||||
height=28,
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
if name:
|
||||
name_entry.insert(0, name)
|
||||
name_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew")
|
||||
|
||||
kw_entry = ctk.CTkEntry(
|
||||
grid_inner,
|
||||
placeholder_text="Patrón / Keyword",
|
||||
height=28,
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
if keyword:
|
||||
kw_entry.insert(0, keyword)
|
||||
kw_entry.grid(row=0, column=1, padx=(0, 5), sticky="ew")
|
||||
|
||||
def _remove():
|
||||
# Buscar y eliminar de la lista interna
|
||||
for i, r_data in enumerate(self._doc_type_rows):
|
||||
if r_data["frame"] == row:
|
||||
self._doc_type_rows.pop(i)
|
||||
break
|
||||
row.destroy()
|
||||
|
||||
remove_btn = ctk.CTkButton(
|
||||
grid_inner,
|
||||
text="×",
|
||||
width=30,
|
||||
height=28,
|
||||
fg_color=self.colors["danger"],
|
||||
hover_color=self.colors["danger_hover"],
|
||||
command=_remove
|
||||
)
|
||||
remove_btn.grid(row=0, column=2, sticky="e")
|
||||
|
||||
self._doc_type_rows.append({
|
||||
"frame": row,
|
||||
"name_entry": name_entry,
|
||||
"kw_entry": kw_entry,
|
||||
"remove_btn": remove_btn
|
||||
})
|
||||
|
||||
# --- API Pública ---
|
||||
|
||||
def set_on_select_root(self, callback):
|
||||
"""Vincula callback al botón de selección de carpeta raíz."""
|
||||
self.root_button.configure(command=callback)
|
||||
|
||||
def set_on_select_csv(self, callback):
|
||||
"""Vincula callback al botón de selección de CSV."""
|
||||
self.csv_button.configure(command=callback)
|
||||
|
||||
def set_on_process(self, callback):
|
||||
"""Vincula callback al botón de procesar."""
|
||||
self.process_button.configure(command=callback)
|
||||
|
||||
def set_on_mode_change(self, callback):
|
||||
"""Vincula callback al cambio de modo."""
|
||||
self.mode_var.trace("w", lambda *args: callback())
|
||||
|
||||
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'."""
|
||||
try:
|
||||
v = (self.mode_var.get() or "").strip().lower()
|
||||
return v if v in ("radicados", "general") else "radicados"
|
||||
except Exception:
|
||||
return "radicados"
|
||||
|
||||
def set_process_mode(self, mode: str) -> None:
|
||||
"""Establece el modo de procesamiento y refresca la UI."""
|
||||
try:
|
||||
normalized = (mode or "").strip().lower()
|
||||
self.mode_var.set(normalized if normalized in ("radicados", "general") else "radicados")
|
||||
self.update_ui_for_mode()
|
||||
except Exception:
|
||||
self.mode_var.set("radicados")
|
||||
self.update_ui_for_mode()
|
||||
|
||||
def get_keywords(self) -> str:
|
||||
"""Devuelve los keywords."""
|
||||
return self.kw_entry.get().strip()
|
||||
|
||||
def get_fields(self) -> str:
|
||||
"""Devuelve los fields (legacy)."""
|
||||
return self.fields_entry.get().strip()
|
||||
|
||||
def get_tiff_start_page(self) -> int:
|
||||
"""Devuelve la página de inicio TIFF."""
|
||||
try:
|
||||
value = self.tiff_start_entry.get().strip()
|
||||
return int(value) if value else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_tiff_end_page(self) -> int:
|
||||
"""Devuelve la página final TIFF."""
|
||||
try:
|
||||
value = self.tiff_end_entry.get().strip()
|
||||
return int(value) if value else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_retry_empty_enabled(self) -> bool:
|
||||
"""Indica si se debe reintentar cuando la fila solo trae el radicado."""
|
||||
try:
|
||||
return bool(self.retry_empty_var.get())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def set_retry_empty_enabled(self, enabled: bool) -> None:
|
||||
"""Establece el reintento automático por fila vacía."""
|
||||
self.retry_empty_var.set(bool(enabled))
|
||||
|
||||
def get_radicado_filter_enabled(self) -> bool:
|
||||
"""Indica si el filtro de radicados está activo."""
|
||||
try:
|
||||
return bool(self.filter_enabled_var.get())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_radicado_filter_list(self) -> list[str]:
|
||||
"""Devuelve la lista de radicados del filtro (sin blancos ni duplicados)."""
|
||||
try:
|
||||
raw = self.filter_textbox.get("1.0", "end")
|
||||
items = []
|
||||
for part in raw.replace(",", "\n").splitlines():
|
||||
s = part.strip()
|
||||
if s:
|
||||
items.append(s)
|
||||
return list(dict.fromkeys(items)) # preserva orden, elimina duplicados
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def set_radicado_filter_enabled(self, enabled: bool) -> None:
|
||||
"""Activa o desactiva el filtro y refresca la UI."""
|
||||
self.filter_enabled_var.set(bool(enabled))
|
||||
self._on_filter_toggle()
|
||||
|
||||
def set_radicado_filter_list(self, radicados: list[str]) -> None:
|
||||
"""Carga una lista de radicados en el área de texto del filtro."""
|
||||
try:
|
||||
self.filter_textbox.delete("1.0", "end")
|
||||
self.filter_textbox.insert("1.0", "\n".join(radicados))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def show_tiff_range_controls(self, show: bool) -> None:
|
||||
"""Muestra u oculta los controles de rango TIFF."""
|
||||
if show:
|
||||
self.tiff_start_label.grid(row=0, column=0, padx=5, pady=5)
|
||||
self.tiff_start_entry.grid(row=0, column=1, padx=5, pady=5)
|
||||
self.tiff_end_label.grid(row=0, column=2, padx=5, pady=5)
|
||||
self.tiff_end_entry.grid(row=0, column=3, padx=5, pady=5)
|
||||
else:
|
||||
self.tiff_start_label.grid_forget()
|
||||
self.tiff_start_entry.grid_forget()
|
||||
self.tiff_end_label.grid_forget()
|
||||
self.tiff_end_entry.grid_forget()
|
||||
|
||||
def get_doc_types(self) -> list[dict]:
|
||||
"""Devuelve la lista de tipos de documento."""
|
||||
out = []
|
||||
for it in self._doc_type_rows:
|
||||
try:
|
||||
name = it["name_entry"].get().strip()
|
||||
kw = it["kw_entry"].get().strip()
|
||||
if name and kw:
|
||||
out.append({"name": name, "keyword": kw})
|
||||
except:
|
||||
pass
|
||||
return out
|
||||
|
||||
def set_doc_types(self, doc_types: list[dict]) -> None:
|
||||
"""Establece los tipos de documento."""
|
||||
for it in self._doc_type_rows:
|
||||
it["frame"].destroy()
|
||||
self._doc_type_rows = []
|
||||
|
||||
if not doc_types:
|
||||
self._add_doc_type_row("Factura", "Factura_O_Documento_Equivalente")
|
||||
return
|
||||
|
||||
for dt in doc_types:
|
||||
self._add_doc_type_row(str(dt.get("name", "")), str(dt.get("keyword", "")))
|
||||
|
||||
def validate_doc_types(self) -> tuple[bool, str | None]:
|
||||
"""Valida los tipos de documento."""
|
||||
seen = set()
|
||||
for dt in self.get_doc_types():
|
||||
name = dt.get("name")
|
||||
if name in seen:
|
||||
return False, f"Nombre duplicado: {name}"
|
||||
seen.add(name)
|
||||
if not seen:
|
||||
return False, "Debe haber al menos un tipo de documento."
|
||||
return True, None
|
||||
|
||||
def set_allowed_document_types_hint(self, allowed_types: list[str]) -> None:
|
||||
"""Actualiza el texto de ayuda de tipos válidos."""
|
||||
text = ", ".join(allowed_types)
|
||||
self.doc_types_hint.configure(text=f"Tipos válidos para documento_forzate: {text}. NIT no aplica.")
|
||||
|
||||
def update_ui_for_mode(self):
|
||||
"""Actualiza la visibilidad de componentes según el modo seleccionado."""
|
||||
mode = self.get_process_mode()
|
||||
if mode == "general":
|
||||
# Ocultar Tipos de Documento
|
||||
self.doc_types_container.grid_forget()
|
||||
# Mostrar Keywords (opcional, para filtrar)
|
||||
self.kw_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
|
||||
self.kw_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
|
||||
|
||||
# Limpiar valor por defecto si existe
|
||||
curr = self.kw_entry.get()
|
||||
if "Factura_O_Documento_Equivalente" in curr:
|
||||
self.kw_entry.delete(0, "end")
|
||||
else:
|
||||
# Mostrar Tipos de Documento
|
||||
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
|
||||
# Ocultar Keywords
|
||||
self.kw_label.grid_forget()
|
||||
self.kw_entry.grid_forget()
|
||||
|
||||
def append_output(self, text: str) -> None:
|
||||
"""Añade texto al área de salida."""
|
||||
self.output_text.insert("end", text + "\n")
|
||||
self.output_text.see("end")
|
||||
|
||||
def append_output_safe(self, text: str) -> None:
|
||||
"""Añade texto al área de salida de forma thread-safe."""
|
||||
# Nota: se espera que sea llamado con self.after(0, ...)
|
||||
self.append_output(text)
|
||||
|
||||
def enable_controls(self, enabled: bool):
|
||||
"""Habilita o deshabilita los controles."""
|
||||
state = "normal" if enabled else "disabled"
|
||||
|
||||
self.process_button.configure(state=state, text="PROCESANDO..." if not enabled else "INICIAR PROCESAMIENTO")
|
||||
if not enabled:
|
||||
self.process_button.configure(fg_color="gray")
|
||||
else:
|
||||
self.process_button.configure(fg_color=self.colors["primary_button"])
|
||||
|
||||
self.root_button.configure(state=state)
|
||||
self.root_entry.configure(state=state)
|
||||
self.csv_button.configure(state=state)
|
||||
self.csv_entry.configure(state=state)
|
||||
self.add_doc_type_btn.configure(state=state)
|
||||
self.retry_empty_check.configure(state=state)
|
||||
self.filter_check.configure(state=state)
|
||||
self.filter_textbox.configure(state=state)
|
||||
|
||||
for row in self._doc_type_rows:
|
||||
row["name_entry"].configure(state=state)
|
||||
row["kw_entry"].configure(state=state)
|
||||
row["remove_btn"].configure(state=state)
|
||||
@@ -1,351 +0,0 @@
|
||||
"""Pestaña Dashboard & Métricas de la aplicación GUI.
|
||||
|
||||
Muestra KPIs, gráficos y detalle de costos para Extracción, Normalización
|
||||
y Post-Procesamiento.
|
||||
"""
|
||||
import customtkinter as ctk
|
||||
import tkinter.ttk as ttk
|
||||
from gui.styles import AppStyles
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import numpy as np
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
HAS_MPL = True
|
||||
except Exception:
|
||||
HAS_MPL = False
|
||||
|
||||
|
||||
class MetricsTab:
|
||||
"""Encapsula la UI de la pestaña Dashboard & Métricas."""
|
||||
|
||||
def __init__(self, parent_tab):
|
||||
self.tab = parent_tab
|
||||
self.fonts = AppStyles.get_fonts()
|
||||
self.colors = AppStyles.get_colors()
|
||||
self.spacing = AppStyles.get_spacing()
|
||||
|
||||
self._cost_line_points_x: list[int] = []
|
||||
self._cost_line_points_y: list[float] = []
|
||||
|
||||
self._pp_history: list[dict] = []
|
||||
|
||||
self._setup_layout()
|
||||
self._create_kpi_section()
|
||||
self._create_pp_kpi_section()
|
||||
self._create_charts_section()
|
||||
self._create_comparison_section()
|
||||
self._create_detail_section()
|
||||
|
||||
# -- Layout ----------------------------------------------------------------
|
||||
|
||||
def _setup_layout(self):
|
||||
self.tab.grid_columnconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(0, weight=0)
|
||||
|
||||
self.scroll = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
|
||||
self.scroll.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
self.scroll.grid_columnconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(0, weight=1)
|
||||
|
||||
# -- KPI cards helper ------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _make_kpi_card(parent, title, row, col, accent=None):
|
||||
card = ctk.CTkFrame(parent)
|
||||
card.grid(row=row, column=col, sticky="nsew", padx=4, pady=4)
|
||||
card.grid_columnconfigure(0, weight=1)
|
||||
|
||||
header = ctk.CTkFrame(card, height=6, fg_color=accent or ("#1f77b4", "#1f77b4"), corner_radius=0)
|
||||
header.grid(row=0, column=0, sticky="ew")
|
||||
header.grid_propagate(False)
|
||||
|
||||
lbl_title = ctk.CTkLabel(card, text=title, font=ctk.CTkFont(size=12, weight="bold"))
|
||||
lbl_title.grid(row=1, column=0, sticky="w", padx=12, pady=(8, 2))
|
||||
|
||||
lbl_value = ctk.CTkLabel(card, text="—", font=ctk.CTkFont(family="Consolas", size=11), justify="left")
|
||||
lbl_value.grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
|
||||
|
||||
return lbl_value
|
||||
|
||||
# -- Extracción KPIs -------------------------------------------------------
|
||||
|
||||
def _create_kpi_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=0, column=0, sticky="ew", padx=2, pady=(2, 1))
|
||||
frame.grid_columnconfigure((0, 1, 2, 3), weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Extracción de Documentos", font=ctk.CTkFont(size=15, weight="bold"))
|
||||
lbl.grid(row=0, column=0, columnspan=4, sticky="w", padx=12, pady=(10, 4))
|
||||
|
||||
self.kpi_overall = self._make_kpi_card(frame, "Total Sesión", 1, 0, accent=("#2E86C1", "#2E86C1"))
|
||||
self.kpi_extract = self._make_kpi_card(frame, "Fase: Extracción", 1, 1, accent=("#1f77b4", "#1f77b4"))
|
||||
self.kpi_normalize = self._make_kpi_card(frame, "Fase: Normalización", 1, 2, accent=("#ff7f0e", "#ff7f0e"))
|
||||
self.kpi_docs = self._make_kpi_card(frame, "Documentos", 1, 3, accent=("#27AE60", "#27AE60"))
|
||||
|
||||
zero = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
|
||||
self.kpi_overall.configure(text=zero)
|
||||
self.kpi_extract.configure(text=zero)
|
||||
self.kpi_normalize.configure(text=zero)
|
||||
self.kpi_docs.configure(text="Procesados: 0\nCosto prom: $0.0000")
|
||||
|
||||
# -- Post-Procesamiento KPIs ----------------------------------------------
|
||||
|
||||
def _create_pp_kpi_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=1, column=0, sticky="ew", padx=2, pady=1)
|
||||
frame.grid_columnconfigure((0, 1, 2, 3), weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Post-Procesamiento (Agente Codex)", font=ctk.CTkFont(size=15, weight="bold"))
|
||||
lbl.grid(row=0, column=0, columnspan=4, sticky="w", padx=12, pady=(10, 4))
|
||||
|
||||
self.kpi_pp_total = self._make_kpi_card(frame, "Total Acumulado", 1, 0, accent=("#8E44AD", "#8E44AD"))
|
||||
self.kpi_pp_calls = self._make_kpi_card(frame, "Llamadas API", 1, 1, accent=("#9B59B6", "#9B59B6"))
|
||||
self.kpi_pp_tokens = self._make_kpi_card(frame, "Tokens", 1, 2, accent=("#AF7AC5", "#AF7AC5"))
|
||||
self.kpi_pp_last = self._make_kpi_card(frame, "Última ejecución", 1, 3, accent=("#D2B4DE", "#D2B4DE"))
|
||||
|
||||
self.kpi_pp_total.configure(text="USD: $0.0000")
|
||||
self.kpi_pp_calls.configure(text="Total: 0\nReintentos: 0")
|
||||
self.kpi_pp_tokens.configure(text="input: 0\noutput: 0")
|
||||
self.kpi_pp_last.configure(text="—")
|
||||
|
||||
# -- Charts ----------------------------------------------------------------
|
||||
|
||||
def _create_charts_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=2, column=0, sticky="ew", padx=2, pady=1)
|
||||
frame.grid_columnconfigure((0, 1), weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Gráficos", font=ctk.CTkFont(size=15, weight="bold"))
|
||||
lbl.grid(row=0, column=0, columnspan=2, sticky="w", padx=12, pady=(10, 4))
|
||||
|
||||
if HAS_MPL:
|
||||
bg = "#2b2b2b"
|
||||
|
||||
self.fig_line = Figure(figsize=(4.5, 2.8), dpi=100, facecolor=bg)
|
||||
self.ax_line = self.fig_line.add_subplot(111)
|
||||
self._style_axis(self.ax_line, "Costo Acumulado por Documento (USD)")
|
||||
self._line_plot, = self.ax_line.plot([], [], marker="o", markersize=4, linewidth=1.5, color="#2ecc71")
|
||||
self.fig_line.tight_layout(pad=1.5)
|
||||
|
||||
self.canvas_line = FigureCanvasTkAgg(self.fig_line, master=frame)
|
||||
self.canvas_line.get_tk_widget().grid(row=1, column=0, padx=8, pady=(0, 8), sticky="nsew")
|
||||
|
||||
self.fig_bars = Figure(figsize=(4.5, 2.8), dpi=100, facecolor=bg)
|
||||
self.ax_bars = self.fig_bars.add_subplot(111)
|
||||
self._style_axis(self.ax_bars, "Tokens por Fase")
|
||||
self.fig_bars.tight_layout(pad=1.5)
|
||||
|
||||
self.canvas_bars = FigureCanvasTkAgg(self.fig_bars, master=frame)
|
||||
self.canvas_bars.get_tk_widget().grid(row=1, column=1, padx=8, pady=(0, 8), sticky="nsew")
|
||||
else:
|
||||
ctk.CTkLabel(frame, text="matplotlib no disponible — instálelo para gráficos").grid(
|
||||
row=1, column=0, columnspan=2, pady=10,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _style_axis(ax, title):
|
||||
bg = "#2b2b2b"
|
||||
fg = "#cccccc"
|
||||
ax.set_facecolor(bg)
|
||||
ax.set_title(title, fontsize=10, color=fg, pad=8)
|
||||
ax.tick_params(colors=fg, labelsize=8)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color("#555555")
|
||||
ax.xaxis.label.set_color(fg)
|
||||
ax.yaxis.label.set_color(fg)
|
||||
|
||||
# -- Comparison table ------------------------------------------------------
|
||||
|
||||
def _create_comparison_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=3, column=0, sticky="ew", padx=2, pady=1)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Tarifas de Modelos (USD / 1M tokens)", font=ctk.CTkFont(size=15, weight="bold"))
|
||||
lbl.grid(row=0, column=0, sticky="w", padx=12, pady=(10, 4))
|
||||
|
||||
style = ttk.Style()
|
||||
style.configure("Metrics.Treeview", rowheight=26, font=("Consolas", 10))
|
||||
style.configure("Metrics.Treeview.Heading", font=("Roboto", 11, "bold"))
|
||||
|
||||
self.tree_comparison = ttk.Treeview(
|
||||
frame,
|
||||
columns=("model", "input", "cached", "output"),
|
||||
show="headings", height=6, style="Metrics.Treeview",
|
||||
)
|
||||
self.tree_comparison.heading("model", text="Modelo")
|
||||
self.tree_comparison.heading("input", text="Input")
|
||||
self.tree_comparison.heading("cached", text="Cached Input")
|
||||
self.tree_comparison.heading("output", text="Output")
|
||||
|
||||
self.tree_comparison.column("model", width=180, anchor="w")
|
||||
self.tree_comparison.column("input", width=110, anchor="e")
|
||||
self.tree_comparison.column("cached", width=110, anchor="e")
|
||||
self.tree_comparison.column("output", width=110, anchor="e")
|
||||
|
||||
self.tree_comparison.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10))
|
||||
|
||||
# -- Detail log ------------------------------------------------------------
|
||||
|
||||
def _create_detail_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=4, column=0, sticky="ew", padx=2, pady=(1, 2))
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Detalle de uso (log)", font=ctk.CTkFont(size=15, weight="bold"))
|
||||
lbl.grid(row=0, column=0, sticky="w", padx=12, pady=(10, 4))
|
||||
|
||||
self.usage_text = ctk.CTkTextbox(frame, height=160, font=self.fonts["mono_small"], wrap="none")
|
||||
self.usage_text.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10))
|
||||
|
||||
# =========================================================================
|
||||
# API PÚBLICA
|
||||
# =========================================================================
|
||||
|
||||
# -- Extracción KPIs -------------------------------------------------------
|
||||
|
||||
def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None:
|
||||
def fmt(d):
|
||||
return (
|
||||
f"input: {d.get('input', 0):,}\n"
|
||||
f"cached: {d.get('cached', 0):,}\n"
|
||||
f"output: {d.get('output', 0):,}\n"
|
||||
f"USD: ${d.get('usd', 0.0):.4f}"
|
||||
)
|
||||
self.kpi_overall.configure(text=fmt(overall))
|
||||
self.kpi_extract.configure(text=fmt(extract_phase))
|
||||
self.kpi_normalize.configure(text=fmt(normalize_phase))
|
||||
|
||||
def update_doc_kpi(self, processed: int, avg_cost: float) -> None:
|
||||
self.kpi_docs.configure(text=f"Procesados: {processed}\nCosto prom: ${avg_cost:.4f}")
|
||||
|
||||
# -- Post-Procesamiento KPIs ----------------------------------------------
|
||||
|
||||
def update_pp_kpis(
|
||||
self,
|
||||
total_usd: float,
|
||||
total_calls: int,
|
||||
total_retries: int,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
last_description: str,
|
||||
) -> None:
|
||||
self.kpi_pp_total.configure(text=f"USD: ${total_usd:.4f}")
|
||||
self.kpi_pp_calls.configure(text=f"Total: {total_calls}\nReintentos: {total_retries}")
|
||||
self.kpi_pp_tokens.configure(text=f"input: {input_tokens:,}\noutput: {output_tokens:,}")
|
||||
self.kpi_pp_last.configure(text=last_description or "—")
|
||||
|
||||
# -- Charts ----------------------------------------------------------------
|
||||
|
||||
def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None:
|
||||
if not HAS_MPL:
|
||||
return
|
||||
try:
|
||||
self._cost_line_points_x.append(doc_index)
|
||||
self._cost_line_points_y.append(total_cost_usd)
|
||||
self._line_plot.set_data(self._cost_line_points_x, self._cost_line_points_y)
|
||||
self.ax_line.relim()
|
||||
self.ax_line.autoscale_view()
|
||||
self.canvas_line.draw_idle()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_token_bars(
|
||||
self,
|
||||
input_extract: int, cached_extract: int, output_extract: int,
|
||||
input_norm: int, cached_norm: int, output_norm: int,
|
||||
input_pp: int = 0, output_pp: int = 0,
|
||||
) -> None:
|
||||
if not HAS_MPL:
|
||||
return
|
||||
try:
|
||||
self.ax_bars.clear()
|
||||
self._style_axis(self.ax_bars, "Tokens por Fase")
|
||||
|
||||
labels = ["Input", "Cached", "Output"]
|
||||
extract_vals = [input_extract, cached_extract, output_extract]
|
||||
norm_vals = [input_norm, cached_norm, output_norm]
|
||||
pp_vals = [input_pp, 0, output_pp]
|
||||
|
||||
has_pp = input_pp > 0 or output_pp > 0
|
||||
|
||||
x = np.arange(len(labels))
|
||||
width = 0.25 if has_pp else 0.35
|
||||
offset = width
|
||||
|
||||
self.ax_bars.bar(x - offset, extract_vals, width, label="Extracción", color="#3498db")
|
||||
self.ax_bars.bar(x, norm_vals, width, label="Normalización", color="#e67e22")
|
||||
if has_pp:
|
||||
self.ax_bars.bar(x + offset, pp_vals, width, label="Post-Proc.", color="#9b59b6")
|
||||
|
||||
self.ax_bars.set_xticks(x, labels)
|
||||
leg = self.ax_bars.legend(fontsize=8, framealpha=0.6)
|
||||
for text in leg.get_texts():
|
||||
text.set_color("#cccccc")
|
||||
self.fig_bars.tight_layout(pad=1.5)
|
||||
self.canvas_bars.draw_idle()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- Comparison table ------------------------------------------------------
|
||||
|
||||
def update_comparison_table(self, comparisons: list[dict]) -> None:
|
||||
try:
|
||||
for item in self.tree_comparison.get_children():
|
||||
self.tree_comparison.delete(item)
|
||||
for c in comparisons:
|
||||
self.tree_comparison.insert("", "end", values=(
|
||||
c.get("model", "N/D"),
|
||||
f"${c.get('input', 0.0):.3f}",
|
||||
f"${c.get('cached_input', 0.0):.4f}",
|
||||
f"${c.get('output', 0.0):.2f}",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- Detail log ------------------------------------------------------------
|
||||
|
||||
def append_usage(self, text: str) -> None:
|
||||
self.usage_text.insert("end", text + "\n")
|
||||
self.usage_text.see("end")
|
||||
|
||||
def append_usage_safe(self, text: str) -> None:
|
||||
self.append_usage(text)
|
||||
|
||||
def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None:
|
||||
try:
|
||||
usd = (cost or {}).get("total_usd", 0.0)
|
||||
prompt = int((usage or {}).get("prompt_tokens", 0) or 0)
|
||||
comp = int((usage or {}).get("completion_tokens", 0) or 0)
|
||||
self.append_usage(
|
||||
f"[{phase:<12}] {radicado:<22} | in={prompt:>7,} out={comp:>6,} | ${usd:.4f}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
self.usage_text.delete("1.0", "end")
|
||||
|
||||
zero = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
|
||||
self.kpi_overall.configure(text=zero)
|
||||
self.kpi_extract.configure(text=zero)
|
||||
self.kpi_normalize.configure(text=zero)
|
||||
self.kpi_docs.configure(text="Procesados: 0\nCosto prom: $0.0000")
|
||||
|
||||
if HAS_MPL:
|
||||
self._cost_line_points_x.clear()
|
||||
self._cost_line_points_y.clear()
|
||||
self._line_plot.set_data([], [])
|
||||
self.ax_line.relim()
|
||||
self.ax_line.autoscale_view()
|
||||
self.canvas_line.draw_idle()
|
||||
|
||||
self.ax_bars.clear()
|
||||
self._style_axis(self.ax_bars, "Tokens por Fase")
|
||||
self.canvas_bars.draw_idle()
|
||||
|
||||
def set_usage_totals(self, **kwargs):
|
||||
pass
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Pestaña de Post-Procesamiento con agente inteligente."""
|
||||
import customtkinter as ctk
|
||||
from gui.styles import AppStyles
|
||||
|
||||
|
||||
class PostProcessTab:
|
||||
"""UI de la pestaña de Post-Procesamiento de CSV."""
|
||||
|
||||
def __init__(self, parent_tab):
|
||||
self.tab = parent_tab
|
||||
self.fonts = AppStyles.get_fonts()
|
||||
self.colors = AppStyles.get_colors()
|
||||
self.spacing = AppStyles.get_spacing()
|
||||
|
||||
self._setup_layout()
|
||||
self._create_file_section()
|
||||
self._create_preview_section()
|
||||
self._create_instructions_section()
|
||||
self._create_code_section()
|
||||
self._create_log_section()
|
||||
self._create_result_section()
|
||||
|
||||
# -- Layout ------------------------------------------------------------------
|
||||
|
||||
def _setup_layout(self):
|
||||
self.tab.grid_columnconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(0, weight=1)
|
||||
|
||||
self.scroll = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
|
||||
self.scroll.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
|
||||
self.scroll.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# -- Sección archivo ---------------------------------------------------------
|
||||
|
||||
def _create_file_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 2))
|
||||
frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
lbl_title = ctk.CTkLabel(frame, text="Archivo CSV", font=self.fonts["section"])
|
||||
lbl_title.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(10, 5))
|
||||
|
||||
self.btn_select_csv = ctk.CTkButton(
|
||||
frame, text="Seleccionar CSV", font=self.fonts["ui"], width=160,
|
||||
)
|
||||
self.btn_select_csv.grid(row=1, column=0, padx=15, pady=8, sticky="w")
|
||||
|
||||
self.lbl_csv_path = ctk.CTkLabel(
|
||||
frame, text="Ningún archivo seleccionado",
|
||||
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
|
||||
)
|
||||
self.lbl_csv_path.grid(row=1, column=1, padx=10, pady=8, sticky="w")
|
||||
|
||||
# -- Preview datos -----------------------------------------------------------
|
||||
|
||||
def _create_preview_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=1, column=0, sticky="ew", padx=5, pady=2)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Vista previa (muestra)", font=self.fonts["section"])
|
||||
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 5))
|
||||
|
||||
self.txt_preview = ctk.CTkTextbox(
|
||||
frame, height=200, font=self.fonts["mono_small"],
|
||||
state="disabled", wrap="none",
|
||||
)
|
||||
self.txt_preview.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
|
||||
|
||||
# -- Instrucciones -----------------------------------------------------------
|
||||
|
||||
def _create_instructions_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=2, column=0, sticky="ew", padx=5, pady=2)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(
|
||||
frame, text="Instrucciones de transformación", font=self.fonts["section"],
|
||||
)
|
||||
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 2))
|
||||
|
||||
hint = ctk.CTkLabel(
|
||||
frame,
|
||||
text="Describe qué cambios quieres aplicar y en qué columnas. Ejemplo: "
|
||||
"\"En la columna 'monto', multiplicar por 1.19 para agregar IVA\"",
|
||||
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
|
||||
wraplength=900, justify="left",
|
||||
)
|
||||
hint.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 5))
|
||||
|
||||
self.txt_instructions = ctk.CTkTextbox(
|
||||
frame, height=90, font=self.fonts["ui"], wrap="word",
|
||||
)
|
||||
self.txt_instructions.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 5))
|
||||
|
||||
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
btn_frame.grid(row=3, column=0, sticky="ew", padx=15, pady=(0, 10))
|
||||
|
||||
self.btn_run = ctk.CTkButton(
|
||||
btn_frame, text="Generar y Aplicar", font=self.fonts["button_large"],
|
||||
fg_color=self.colors["primary_button"], hover_color=self.colors["primary_hover"],
|
||||
height=38,
|
||||
)
|
||||
self.btn_run.pack(side="left")
|
||||
|
||||
self.lbl_status = ctk.CTkLabel(
|
||||
btn_frame, text="", font=self.fonts["italic_small"],
|
||||
text_color=self.colors["gray_light"],
|
||||
)
|
||||
self.lbl_status.pack(side="left", padx=15)
|
||||
|
||||
# -- Código generado ---------------------------------------------------------
|
||||
|
||||
def _create_code_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=3, column=0, sticky="ew", padx=5, pady=2)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Código generado", font=self.fonts["section"])
|
||||
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 5))
|
||||
|
||||
self.txt_code = ctk.CTkTextbox(
|
||||
frame, height=180, font=self.fonts["mono_small"],
|
||||
state="disabled", wrap="none",
|
||||
)
|
||||
self.txt_code.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
|
||||
|
||||
# -- Log de ejecución --------------------------------------------------------
|
||||
|
||||
def _create_log_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=4, column=0, sticky="ew", padx=5, pady=2)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(frame, text="Log de ejecución", font=self.fonts["section"])
|
||||
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 5))
|
||||
|
||||
self.txt_log = ctk.CTkTextbox(
|
||||
frame, height=100, font=self.fonts["mono_small"],
|
||||
state="disabled", wrap="word",
|
||||
)
|
||||
self.txt_log.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
|
||||
|
||||
# -- Resultado ---------------------------------------------------------------
|
||||
|
||||
def _create_result_section(self):
|
||||
frame = ctk.CTkFrame(self.scroll)
|
||||
frame.grid(row=5, column=0, sticky="ew", padx=5, pady=(2, 5))
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
top = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
top.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 5))
|
||||
top.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl = ctk.CTkLabel(top, text="Vista previa del resultado", font=self.fonts["section"])
|
||||
lbl.pack(side="left")
|
||||
|
||||
self.btn_save = ctk.CTkButton(
|
||||
top, text="Guardar CSV", font=self.fonts["ui"], width=140,
|
||||
fg_color=self.colors["success"], hover_color=self.colors["success_hover"],
|
||||
state="disabled",
|
||||
)
|
||||
self.btn_save.pack(side="right")
|
||||
|
||||
self.txt_result = ctk.CTkTextbox(
|
||||
frame, height=200, font=self.fonts["mono_small"],
|
||||
state="disabled", wrap="none",
|
||||
)
|
||||
self.txt_result.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
|
||||
|
||||
# -- API pública -------------------------------------------------------------
|
||||
|
||||
def set_on_select_csv(self, callback):
|
||||
self.btn_select_csv.configure(command=callback)
|
||||
|
||||
def set_on_run(self, callback):
|
||||
self.btn_run.configure(command=callback)
|
||||
|
||||
def set_on_save(self, callback):
|
||||
self.btn_save.configure(command=callback)
|
||||
|
||||
def set_csv_path(self, path: str):
|
||||
self.lbl_csv_path.configure(text=path or "Ningún archivo seleccionado")
|
||||
|
||||
def get_instructions(self) -> str:
|
||||
return self.txt_instructions.get("1.0", "end").strip()
|
||||
|
||||
def set_status(self, text: str):
|
||||
self.lbl_status.configure(text=text)
|
||||
|
||||
def set_preview(self, text: str):
|
||||
self.txt_preview.configure(state="normal")
|
||||
self.txt_preview.delete("1.0", "end")
|
||||
self.txt_preview.insert("1.0", text)
|
||||
self.txt_preview.configure(state="disabled")
|
||||
|
||||
def set_code(self, text: str):
|
||||
self.txt_code.configure(state="normal")
|
||||
self.txt_code.delete("1.0", "end")
|
||||
self.txt_code.insert("1.0", text)
|
||||
self.txt_code.configure(state="disabled")
|
||||
|
||||
def append_log(self, text: str):
|
||||
self.txt_log.configure(state="normal")
|
||||
self.txt_log.insert("end", text + "\n")
|
||||
self.txt_log.see("end")
|
||||
self.txt_log.configure(state="disabled")
|
||||
|
||||
def clear_log(self):
|
||||
self.txt_log.configure(state="normal")
|
||||
self.txt_log.delete("1.0", "end")
|
||||
self.txt_log.configure(state="disabled")
|
||||
|
||||
def set_result(self, text: str):
|
||||
self.txt_result.configure(state="normal")
|
||||
self.txt_result.delete("1.0", "end")
|
||||
self.txt_result.insert("1.0", text)
|
||||
self.txt_result.configure(state="disabled")
|
||||
|
||||
def enable_save(self, enabled: bool):
|
||||
self.btn_save.configure(state="normal" if enabled else "disabled")
|
||||
|
||||
def enable_controls(self, enabled: bool):
|
||||
state = "normal" if enabled else "disabled"
|
||||
self.btn_select_csv.configure(state=state)
|
||||
self.btn_run.configure(state=state)
|
||||
self.txt_instructions.configure(state=state)
|
||||
@@ -1,478 +0,0 @@
|
||||
"""Pestaña Preclasificación de la aplicación GUI."""
|
||||
import customtkinter as ctk
|
||||
from gui.styles import AppStyles
|
||||
|
||||
|
||||
class PreclassificationTab:
|
||||
"""Encapsula la UI de la pestaña Preclasificación."""
|
||||
|
||||
def __init__(self, parent_tab):
|
||||
"""Inicializa la pestaña de Preclasificación.
|
||||
|
||||
Args:
|
||||
parent_tab: El CTkFrame que representa la pestaña.
|
||||
"""
|
||||
self.tab = parent_tab
|
||||
self.fonts = AppStyles.get_fonts()
|
||||
self.colors = AppStyles.get_colors()
|
||||
self.spacing = AppStyles.get_spacing()
|
||||
|
||||
# Variables internas
|
||||
self._preclass_pattern_rows = []
|
||||
|
||||
self._setup_layout()
|
||||
self._create_root_section()
|
||||
self._create_options_section()
|
||||
self._create_target_section()
|
||||
self._create_patterns_section()
|
||||
self._create_action_section()
|
||||
self._create_output_section()
|
||||
|
||||
def _setup_layout(self):
|
||||
"""Configura el layout base de la pestaña."""
|
||||
self.tab.grid_columnconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(0, weight=1)
|
||||
|
||||
self.preclass_container = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
|
||||
self.preclass_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
self.preclass_container.grid_columnconfigure(0, weight=1)
|
||||
self.preclass_container.grid_rowconfigure(3, weight=1)
|
||||
self.preclass_container.grid_rowconfigure(5, weight=1)
|
||||
|
||||
def _create_root_section(self):
|
||||
"""Crea la sección de carpeta raíz."""
|
||||
self.preclass_frame_root = ctk.CTkFrame(self.preclass_container)
|
||||
self.preclass_frame_root.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 10))
|
||||
self.preclass_frame_root.grid_columnconfigure(1, weight=1)
|
||||
|
||||
lbl_pre_root_title = ctk.CTkLabel(
|
||||
self.preclass_frame_root,
|
||||
text="Carpeta Raíz (Preclasificación)",
|
||||
font=self.fonts["section"]
|
||||
)
|
||||
lbl_pre_root_title.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(10, 5))
|
||||
|
||||
self.preclass_root_label = ctk.CTkLabel(
|
||||
self.preclass_frame_root,
|
||||
text="Carpeta Raíz:",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.preclass_root_label.grid(row=1, column=0, padx=15, pady=(0, 10), sticky="w")
|
||||
|
||||
self.preclass_root_entry = ctk.CTkEntry(
|
||||
self.preclass_frame_root,
|
||||
placeholder_text="Selecciona la carpeta raíz para preclasificar...",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.preclass_root_entry.grid(row=1, column=1, padx=5, pady=(0, 10), sticky="ew")
|
||||
|
||||
self.preclass_root_button = ctk.CTkButton(
|
||||
self.preclass_frame_root,
|
||||
text="Explorar...",
|
||||
font=self.fonts["ui"],
|
||||
width=100
|
||||
)
|
||||
self.preclass_root_button.grid(row=1, column=2, padx=15, pady=(0, 10))
|
||||
|
||||
# Selector de Modo
|
||||
self.preclass_mode_var = ctk.StringVar(value="radicados")
|
||||
|
||||
lbl_mode = ctk.CTkLabel(self.preclass_frame_root, text="Estructura:", font=self.fonts["ui"])
|
||||
lbl_mode.grid(row=2, column=0, padx=15, pady=(0, 10), sticky="w")
|
||||
|
||||
self.frame_radios = ctk.CTkFrame(self.preclass_frame_root, fg_color="transparent")
|
||||
self.frame_radios.grid(row=2, column=1, columnspan=2, sticky="w", padx=5, pady=(0, 10))
|
||||
|
||||
self.radio_radicados = ctk.CTkRadioButton(
|
||||
self.frame_radios,
|
||||
text="Por Radicados (Subcarpetas)",
|
||||
variable=self.preclass_mode_var,
|
||||
value="radicados",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.radio_radicados.pack(side="left", padx=(0, 20))
|
||||
|
||||
self.radio_general = ctk.CTkRadioButton(
|
||||
self.frame_radios,
|
||||
text="Carpeta General (Archivos Planos)",
|
||||
variable=self.preclass_mode_var,
|
||||
value="general",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.radio_general.pack(side="left")
|
||||
|
||||
def _create_options_section(self):
|
||||
"""Crea la sección de opciones de rango."""
|
||||
self.preclass_frame_options = ctk.CTkFrame(self.preclass_container)
|
||||
self.preclass_frame_options.grid(row=1, column=0, sticky="ew", padx=5, pady=(0, 10))
|
||||
self.preclass_frame_options.grid_columnconfigure(1, weight=1)
|
||||
|
||||
self.preclass_range_var = ctk.BooleanVar(value=False)
|
||||
self.preclass_range_cb = ctk.CTkCheckBox(
|
||||
self.preclass_frame_options,
|
||||
text="Habilitar rangos de página",
|
||||
variable=self.preclass_range_var,
|
||||
font=self.fonts["ui"],
|
||||
command=self._on_toggle_preclass_range
|
||||
)
|
||||
self.preclass_range_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w")
|
||||
|
||||
self.preclass_range_inputs_frame = ctk.CTkFrame(self.preclass_frame_options, fg_color="transparent")
|
||||
|
||||
lbl_desde = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Desde:", font=self.fonts["ui"])
|
||||
lbl_desde.grid(row=0, column=0, padx=(10, 5), pady=5)
|
||||
self.preclass_range_start_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.fonts["ui"])
|
||||
self.preclass_range_start_entry.insert(0, "1")
|
||||
self.preclass_range_start_entry.grid(row=0, column=1, padx=5, pady=5)
|
||||
|
||||
lbl_hasta = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Hasta:", font=self.fonts["ui"])
|
||||
lbl_hasta.grid(row=0, column=2, padx=(10, 5), pady=5)
|
||||
self.preclass_range_end_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.fonts["ui"])
|
||||
self.preclass_range_end_entry.insert(0, "1")
|
||||
self.preclass_range_end_entry.grid(row=0, column=3, padx=5, pady=5)
|
||||
|
||||
def _create_target_section(self):
|
||||
"""Crea la sección de archivo objetivo."""
|
||||
self.preclass_frame_target = ctk.CTkFrame(self.preclass_container)
|
||||
self.preclass_frame_target.grid(row=2, column=0, sticky="ew", padx=5, pady=(0, 10))
|
||||
self.preclass_frame_target.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.preclass_target_var = ctk.BooleanVar(value=False)
|
||||
self.preclass_target_cb = ctk.CTkCheckBox(
|
||||
self.preclass_frame_target,
|
||||
text="Habilitar archivo objetivo",
|
||||
variable=self.preclass_target_var,
|
||||
font=self.fonts["ui"],
|
||||
command=self._on_toggle_preclass_target
|
||||
)
|
||||
self.preclass_target_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w")
|
||||
|
||||
self.preclass_target_inputs_frame = ctk.CTkFrame(self.preclass_frame_target, fg_color="transparent")
|
||||
|
||||
lbl_target_def = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Def. Tipo Doc:", font=self.fonts["ui"])
|
||||
lbl_target_def.grid(row=0, column=0, padx=(15, 5), pady=5, sticky="w")
|
||||
self.preclass_target_def_entry = ctk.CTkEntry(
|
||||
self.preclass_target_inputs_frame,
|
||||
width=250,
|
||||
font=self.fonts["ui"],
|
||||
placeholder_text="Ej: Factura de Venta"
|
||||
)
|
||||
self.preclass_target_def_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
lbl_target_pattern = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Patrón:", font=self.fonts["ui"])
|
||||
lbl_target_pattern.grid(row=0, column=2, padx=(20, 5), pady=5, sticky="w")
|
||||
self.preclass_target_pattern_entry = ctk.CTkEntry(
|
||||
self.preclass_target_inputs_frame,
|
||||
width=200,
|
||||
font=self.fonts["ui"],
|
||||
placeholder_text="Ej: *_FAC_*.pdf"
|
||||
)
|
||||
self.preclass_target_pattern_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w")
|
||||
|
||||
def _create_patterns_section(self):
|
||||
"""Crea la sección de patrones."""
|
||||
self.preclass_frame_patterns = ctk.CTkFrame(self.preclass_container)
|
||||
self.preclass_frame_patterns.grid(row=3, column=0, sticky="nsew", padx=5, pady=5)
|
||||
self.preclass_frame_patterns.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.preclass_patterns_header = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent")
|
||||
self.preclass_patterns_header.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 2))
|
||||
self.preclass_patterns_header.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.preclass_patterns_label = ctk.CTkLabel(
|
||||
self.preclass_patterns_header,
|
||||
text="Patrones",
|
||||
font=self.fonts["section"]
|
||||
)
|
||||
self.preclass_patterns_label.grid(row=0, column=0, sticky="w")
|
||||
|
||||
self.add_preclass_pattern_btn = ctk.CTkButton(
|
||||
self.preclass_patterns_header,
|
||||
text="+ Añadir Patrón",
|
||||
font=self.fonts["ui"],
|
||||
width=140,
|
||||
height=28
|
||||
)
|
||||
self.add_preclass_pattern_btn.grid(row=0, column=1, sticky="e")
|
||||
self.add_preclass_pattern_btn.configure(command=self._add_preclass_pattern_row)
|
||||
|
||||
self.preclass_patterns_hint = ctk.CTkLabel(
|
||||
self.preclass_frame_patterns,
|
||||
text="Agrega patrones de texto. Cada patrón puede ser solo 'key' o 'key + value'.",
|
||||
font=self.fonts["italic_small"],
|
||||
text_color=self.colors["gray_light"],
|
||||
justify="left"
|
||||
)
|
||||
self.preclass_patterns_hint.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 5))
|
||||
|
||||
self.preclass_patterns_list = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent")
|
||||
self.preclass_patterns_list.grid(row=2, column=0, sticky="ew", padx=10, pady=(2, 10))
|
||||
self.preclass_patterns_list.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Fila por defecto (vacía)
|
||||
self._add_preclass_pattern_row()
|
||||
|
||||
def _create_action_section(self):
|
||||
"""Crea la sección de acciones."""
|
||||
self.preclass_action_frame = ctk.CTkFrame(self.preclass_container, fg_color="transparent")
|
||||
self.preclass_action_frame.grid(row=4, column=0, sticky="ew", padx=5, pady=10)
|
||||
self.preclass_action_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.preclass_run_btn = ctk.CTkButton(
|
||||
self.preclass_action_frame,
|
||||
text="EJECUTAR PRECLASIFICACIÓN",
|
||||
font=self.fonts["bold_large"],
|
||||
height=40,
|
||||
fg_color=self.colors["secondary_button"],
|
||||
hover_color=self.colors["secondary_hover"]
|
||||
)
|
||||
self.preclass_run_btn.pack(side="left", fill="x", expand=True, padx=10)
|
||||
|
||||
self.preclass_export_btn = ctk.CTkButton(
|
||||
self.preclass_action_frame,
|
||||
text="Exportar Excel",
|
||||
font=self.fonts["ui"],
|
||||
height=40,
|
||||
state="disabled",
|
||||
fg_color=self.colors["success"],
|
||||
hover_color=self.colors["success_hover"]
|
||||
)
|
||||
self.preclass_export_btn.pack(side="left", padx=(0, 10))
|
||||
|
||||
self.preclass_organize_btn = ctk.CTkButton(
|
||||
self.preclass_action_frame,
|
||||
text="Organizar Carpetas",
|
||||
font=self.fonts["ui"],
|
||||
height=40,
|
||||
state="disabled",
|
||||
fg_color=self.colors["warning"],
|
||||
hover_color=self.colors["warning_hover"]
|
||||
)
|
||||
self.preclass_organize_btn.pack(side="left", padx=(0, 10))
|
||||
|
||||
def _create_output_section(self):
|
||||
"""Crea la sección de salida/logs."""
|
||||
self.preclass_log_frame = ctk.CTkFrame(self.preclass_container)
|
||||
self.preclass_log_frame.grid(row=5, column=0, sticky="nsew", padx=5, pady=(0, 10))
|
||||
self.preclass_log_frame.grid_columnconfigure(0, weight=1)
|
||||
self.preclass_log_frame.grid_rowconfigure(1, weight=1)
|
||||
|
||||
lbl_pre_logs = ctk.CTkLabel(
|
||||
self.preclass_log_frame,
|
||||
text="Resultados:",
|
||||
font=self.fonts["ui"],
|
||||
text_color=self.colors["text_muted"]
|
||||
)
|
||||
lbl_pre_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0))
|
||||
|
||||
self.preclass_output = ctk.CTkTextbox(
|
||||
self.preclass_log_frame,
|
||||
font=self.fonts["mono"],
|
||||
activate_scrollbars=True
|
||||
)
|
||||
self.preclass_output.grid(row=1, column=0, padx=5, pady=5, sticky="nsew")
|
||||
|
||||
def _add_preclass_pattern_row(self, key: str = "", mode: str = "key", value: str = "") -> None:
|
||||
"""Añade una fila editable para un patrón de preclasificación."""
|
||||
row = ctk.CTkFrame(self.preclass_patterns_list, fg_color="transparent")
|
||||
row.pack(fill="x", pady=2)
|
||||
|
||||
grid_inner = ctk.CTkFrame(row, fg_color="transparent")
|
||||
grid_inner.pack(fill="x", padx=5, pady=2)
|
||||
grid_inner.grid_columnconfigure(0, weight=1)
|
||||
grid_inner.grid_columnconfigure(2, weight=1)
|
||||
|
||||
key_entry = ctk.CTkEntry(grid_inner, placeholder_text="Patrón / Key", height=28, font=self.fonts["ui"])
|
||||
if key:
|
||||
key_entry.insert(0, key)
|
||||
key_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew")
|
||||
|
||||
mode_combo = ctk.CTkComboBox(
|
||||
grid_inner,
|
||||
values=["key", "key+value"],
|
||||
width=110,
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
mode_combo.grid(row=0, column=1, padx=(0, 5), sticky="w")
|
||||
|
||||
value_entry = ctk.CTkEntry(
|
||||
grid_inner,
|
||||
placeholder_text="Value (si aplica)",
|
||||
height=28,
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
if value:
|
||||
value_entry.insert(0, value)
|
||||
value_entry.grid(row=0, column=2, padx=(0, 5), sticky="ew")
|
||||
|
||||
def _apply_mode(m: str):
|
||||
m_norm = (m or "").strip().lower()
|
||||
if m_norm in ("key+value", "key + value", "key_value", "key-value"):
|
||||
value_entry.configure(state="normal", placeholder_text="Value (requerido)")
|
||||
else:
|
||||
value_entry.delete(0, "end")
|
||||
value_entry.configure(state="disabled", placeholder_text="Value (no aplica)")
|
||||
|
||||
def _on_mode_change(choice: str):
|
||||
_apply_mode(choice)
|
||||
|
||||
try:
|
||||
mode_combo.set(mode if mode in ("key", "key+value") else "key")
|
||||
except Exception:
|
||||
mode_combo.set("key")
|
||||
|
||||
mode_combo.configure(command=_on_mode_change)
|
||||
_apply_mode(mode_combo.get())
|
||||
|
||||
def _remove():
|
||||
try:
|
||||
for i, r_data in enumerate(list(self._preclass_pattern_rows)):
|
||||
if r_data.get("frame") == row:
|
||||
self._preclass_pattern_rows.pop(i)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
row.destroy()
|
||||
|
||||
remove_btn = ctk.CTkButton(
|
||||
grid_inner,
|
||||
text="×",
|
||||
width=30,
|
||||
height=28,
|
||||
fg_color=self.colors["danger"],
|
||||
hover_color=self.colors["danger_hover"],
|
||||
command=_remove
|
||||
)
|
||||
remove_btn.grid(row=0, column=3, sticky="e")
|
||||
|
||||
self._preclass_pattern_rows.append({
|
||||
"frame": row,
|
||||
"key_entry": key_entry,
|
||||
"mode_combo": mode_combo,
|
||||
"value_entry": value_entry,
|
||||
"remove_btn": remove_btn,
|
||||
})
|
||||
|
||||
def _on_toggle_preclass_range(self):
|
||||
"""Muestra u oculta los inputs de rango según el checkbox."""
|
||||
if self.preclass_range_var.get():
|
||||
self.preclass_range_inputs_frame.grid(row=0, column=1, padx=10, pady=0, sticky="w")
|
||||
else:
|
||||
self.preclass_range_inputs_frame.grid_forget()
|
||||
|
||||
def _on_toggle_preclass_target(self):
|
||||
"""Muestra u oculta los inputs de archivo objetivo según el checkbox."""
|
||||
if self.preclass_target_var.get():
|
||||
self.preclass_target_inputs_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="w")
|
||||
else:
|
||||
self.preclass_target_inputs_frame.grid_forget()
|
||||
|
||||
# --- API Pública ---
|
||||
|
||||
def set_on_run(self, callback):
|
||||
"""Vincula callback al botón de ejecutar."""
|
||||
self.preclass_run_btn.configure(command=callback)
|
||||
|
||||
def set_on_export_excel(self, callback):
|
||||
"""Vincula callback al botón de exportar Excel."""
|
||||
self.preclass_export_btn.configure(command=callback)
|
||||
|
||||
def set_on_organize_files(self, callback):
|
||||
"""Vincula callback al botón de organizar carpetas."""
|
||||
self.preclass_organize_btn.configure(command=callback)
|
||||
|
||||
def set_on_select_root(self, callback):
|
||||
"""Vincula callback al botón de seleccionar carpeta."""
|
||||
self.preclass_root_button.configure(command=callback)
|
||||
|
||||
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:
|
||||
v = (self.preclass_mode_var.get() or "").strip().lower()
|
||||
return v if v in ("radicados", "general") else "radicados"
|
||||
except Exception:
|
||||
return "radicados"
|
||||
|
||||
def get_range(self) -> tuple[bool, int, int]:
|
||||
"""Devuelve (habilitado, desde, hasta) para preclasificación."""
|
||||
enabled = self.preclass_range_var.get()
|
||||
try:
|
||||
start = int(self.preclass_range_start_entry.get().strip() or "1")
|
||||
end = int(self.preclass_range_end_entry.get().strip() or "1")
|
||||
except Exception:
|
||||
start, end = 1, 1
|
||||
return enabled, start, end
|
||||
|
||||
def get_target_info(self) -> dict:
|
||||
"""Devuelve la configuración del archivo objetivo."""
|
||||
return {
|
||||
"enabled": self.preclass_target_var.get(),
|
||||
"definition": self.preclass_target_def_entry.get().strip(),
|
||||
"pattern": self.preclass_target_pattern_entry.get().strip()
|
||||
}
|
||||
|
||||
def get_patterns(self) -> list[dict]:
|
||||
"""Devuelve la lista de patrones definida."""
|
||||
out = []
|
||||
for it in getattr(self, "_preclass_pattern_rows", []):
|
||||
try:
|
||||
key = it["key_entry"].get().strip()
|
||||
mode = (it["mode_combo"].get() or "").strip()
|
||||
value = it["value_entry"].get().strip()
|
||||
if not key:
|
||||
continue
|
||||
if mode == "key+value":
|
||||
out.append({"key": key, "mode": "key+value", "value": value})
|
||||
else:
|
||||
out.append({"key": key, "mode": "key"})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
def append_output(self, text: str) -> None:
|
||||
"""Añade texto al área de salida."""
|
||||
self.preclass_output.insert("end", text + "\n")
|
||||
self.preclass_output.see("end")
|
||||
|
||||
def append_output_safe(self, text: str) -> None:
|
||||
"""Añade texto al área de salida de forma thread-safe."""
|
||||
self.append_output(text)
|
||||
|
||||
def enable_actions(self, enabled: bool):
|
||||
"""Habilita o deshabilita los botones de acción."""
|
||||
state = "normal" if enabled else "disabled"
|
||||
self.preclass_export_btn.configure(state=state)
|
||||
self.preclass_organize_btn.configure(state=state)
|
||||
|
||||
def enable_controls(self, enabled: bool):
|
||||
"""Habilita o deshabilita los controles principales."""
|
||||
state = "normal" if enabled else "disabled"
|
||||
|
||||
self.preclass_root_button.configure(state=state)
|
||||
self.preclass_root_entry.configure(state=state)
|
||||
self.preclass_range_cb.configure(state=state)
|
||||
self.preclass_range_start_entry.configure(state=state)
|
||||
self.preclass_range_end_entry.configure(state=state)
|
||||
self.preclass_target_cb.configure(state=state)
|
||||
self.preclass_target_def_entry.configure(state=state)
|
||||
self.preclass_target_pattern_entry.configure(state=state)
|
||||
self.radio_radicados.configure(state=state)
|
||||
self.radio_general.configure(state=state)
|
||||
self.add_preclass_pattern_btn.configure(state=state)
|
||||
self.preclass_run_btn.configure(state=state)
|
||||
|
||||
for row in self._preclass_pattern_rows:
|
||||
row["key_entry"].configure(state=state)
|
||||
row["mode_combo"].configure(state=state)
|
||||
row["value_entry"].configure(state=state)
|
||||
row["remove_btn"].configure(state=state)
|
||||
@@ -1,304 +0,0 @@
|
||||
"""Pestaña de Resultados de la aplicación GUI."""
|
||||
import os
|
||||
import tkinter as tk
|
||||
import customtkinter as ctk
|
||||
import tkinter.ttk as ttk
|
||||
from gui.styles import AppStyles
|
||||
|
||||
|
||||
class ResultsTab:
|
||||
"""Encapsula la UI de la pestaña Resultados."""
|
||||
|
||||
def __init__(self, parent_tab):
|
||||
"""Inicializa la pestaña de Resultados.
|
||||
|
||||
Args:
|
||||
parent_tab: El CTkFrame que representa la pestaña.
|
||||
"""
|
||||
self.tab = parent_tab
|
||||
self.fonts = AppStyles.get_fonts()
|
||||
self.colors = AppStyles.get_colors()
|
||||
self.spacing = AppStyles.get_spacing()
|
||||
|
||||
self._setup_layout()
|
||||
self._create_subtabs()
|
||||
self._setup_treeview_style()
|
||||
self._create_extractor_tab()
|
||||
self._create_preclass_results_tab()
|
||||
|
||||
def _setup_layout(self):
|
||||
"""Configura el layout base de la pestaña."""
|
||||
self.tab.grid_columnconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(0, weight=1)
|
||||
|
||||
self.tabs_res = ctk.CTkTabview(self.tab)
|
||||
self.tabs_res.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
|
||||
|
||||
def _create_subtabs(self):
|
||||
"""Crea las sub-pestañas de Resultados."""
|
||||
self.tab_res_extractor = self.tabs_res.add("Extractor (Próximamente)")
|
||||
self.tab_res_preclass = self.tabs_res.add("Preclasificación")
|
||||
|
||||
def _setup_treeview_style(self):
|
||||
"""Configura el estilo de los treeviews."""
|
||||
style = ttk.Style()
|
||||
style.theme_use("clam")
|
||||
style.configure("Treeview", rowheight=25)
|
||||
|
||||
def _create_extractor_tab(self):
|
||||
"""Crea la sub-pestaña del Extractor (en construcción)."""
|
||||
lbl_const = ctk.CTkLabel(
|
||||
self.tab_res_extractor,
|
||||
text="En construcción...",
|
||||
font=ctk.CTkFont(size=20)
|
||||
)
|
||||
lbl_const.place(relx=0.5, rely=0.5, anchor="center")
|
||||
|
||||
def _create_preclass_results_tab(self):
|
||||
"""Crea la sub-pestaña de resultados de preclasificación."""
|
||||
self.tab_res_preclass.grid_columnconfigure(0, weight=1)
|
||||
self.tab_res_preclass.grid_rowconfigure(1, weight=1)
|
||||
|
||||
self._create_filters_section()
|
||||
self._create_tables_section()
|
||||
self._create_actions_section()
|
||||
|
||||
def _create_filters_section(self):
|
||||
"""Crea la sección de filtros."""
|
||||
self.frame_res_filters = ctk.CTkFrame(self.tab_res_preclass)
|
||||
self.frame_res_filters.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
|
||||
|
||||
ctk.CTkLabel(self.frame_res_filters, text="Run ID:").grid(row=0, column=0, padx=5, pady=5)
|
||||
self.combo_res_run = ctk.CTkComboBox(self.frame_res_filters, values=["Todos"], width=150)
|
||||
self.combo_res_run.grid(row=0, column=1, padx=5, pady=5)
|
||||
|
||||
ctk.CTkLabel(self.frame_res_filters, text="Estado:").grid(row=0, column=2, padx=5, pady=5)
|
||||
self.combo_res_status = ctk.CTkComboBox(
|
||||
self.frame_res_filters,
|
||||
values=["Todos", "MATCH", "NO_MATCH", "ERROR", "NO_FILES"],
|
||||
width=120
|
||||
)
|
||||
self.combo_res_status.grid(row=0, column=3, padx=5, pady=5)
|
||||
|
||||
self.entry_res_search = ctk.CTkEntry(
|
||||
self.frame_res_filters,
|
||||
placeholder_text="Buscar radicado/archivo...",
|
||||
width=200
|
||||
)
|
||||
self.entry_res_search.grid(row=0, column=4, padx=5, pady=5)
|
||||
|
||||
self.btn_res_search = ctk.CTkButton(self.frame_res_filters, text="Buscar", width=100)
|
||||
self.btn_res_search.grid(row=0, column=5, padx=10, pady=5)
|
||||
|
||||
self.lbl_res_priority = ctk.CTkLabel(
|
||||
self.frame_res_filters,
|
||||
text=(
|
||||
"Filtros estrictos: se combinan (AND) con importancia: "
|
||||
"Run > estado (MATCH, etc.) > criterio de búsqueda. "
|
||||
"Puede ampliar la búsqueda (sin tocar el run) abajo."
|
||||
),
|
||||
text_color=("#5C6370", "#9CA3AF"),
|
||||
font=ctk.CTkFont(size=12),
|
||||
anchor="w",
|
||||
wraplength=920,
|
||||
)
|
||||
self.lbl_res_priority.grid(
|
||||
row=1, column=0, columnspan=6, padx=5, pady=(4, 0), sticky="w"
|
||||
)
|
||||
self._res_relax_var = tk.BooleanVar(value=False)
|
||||
self.chk_res_relax = ctk.CTkCheckBox(
|
||||
self.frame_res_filters,
|
||||
text="Si 0 resultados, ampliar: quitar criterio de búsqueda, luego estado (el run fijado no se descarta).",
|
||||
font=ctk.CTkFont(size=12),
|
||||
variable=self._res_relax_var,
|
||||
)
|
||||
self.chk_res_relax.grid(
|
||||
row=2, column=0, columnspan=6, padx=5, pady=4, sticky="w"
|
||||
)
|
||||
self.lbl_res_filter_note = ctk.CTkLabel(
|
||||
self.frame_res_filters,
|
||||
text="",
|
||||
text_color=("#1D4ED8", "#60A5FA"),
|
||||
font=ctk.CTkFont(size=12),
|
||||
anchor="w",
|
||||
wraplength=920,
|
||||
)
|
||||
self.lbl_res_filter_note.grid(
|
||||
row=3, column=0, columnspan=6, padx=5, pady=(0, 4), sticky="w"
|
||||
)
|
||||
|
||||
def _create_tables_section(self):
|
||||
"""Crea la sección con las tablas (principal y de detalle)."""
|
||||
self.frame_res_table = ctk.CTkFrame(self.tab_res_preclass, fg_color="transparent")
|
||||
self.frame_res_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=5)
|
||||
self.frame_res_table.grid_columnconfigure(0, weight=1)
|
||||
self.frame_res_table.grid_rowconfigure(0, weight=3)
|
||||
self.frame_res_table.grid_rowconfigure(2, weight=2)
|
||||
|
||||
self._create_main_results_table()
|
||||
self._create_detail_label()
|
||||
self._create_matches_table()
|
||||
|
||||
def _create_main_results_table(self):
|
||||
"""Crea la tabla principal de resultados."""
|
||||
cols = ("ID", "Run", "Fecha", "Radicado", "Estado", "Archivo")
|
||||
self.tree_results = ttk.Treeview(
|
||||
self.frame_res_table,
|
||||
columns=cols,
|
||||
show="headings",
|
||||
selectmode="browse"
|
||||
)
|
||||
|
||||
for c in cols:
|
||||
self.tree_results.heading(c, text=c)
|
||||
self.tree_results.column(c, width=100)
|
||||
|
||||
self.tree_results.column("Archivo", width=300)
|
||||
self.tree_results.column("ID", width=50)
|
||||
self.tree_results.column("Run", width=50)
|
||||
|
||||
# Scrollbars
|
||||
sb_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_results.yview)
|
||||
sb_x = ttk.Scrollbar(self.frame_res_table, orient="horizontal", command=self.tree_results.xview)
|
||||
self.tree_results.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set)
|
||||
|
||||
self.tree_results.grid(row=0, column=0, sticky="nsew")
|
||||
sb_y.grid(row=0, column=1, sticky="ns")
|
||||
sb_x.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
def _create_detail_label(self):
|
||||
"""Crea la etiqueta de la sección de detalle."""
|
||||
lbl_detail = ctk.CTkLabel(
|
||||
self.frame_res_table,
|
||||
text="Detalle de Matches (Seleccionar fila arriba)",
|
||||
font=ctk.CTkFont(size=12, weight="bold")
|
||||
)
|
||||
lbl_detail.grid(row=1, column=0, sticky="w", pady=(10, 5))
|
||||
|
||||
def _create_matches_table(self):
|
||||
"""Crea la tabla de detalle de matches."""
|
||||
cols_detail = ("Key", "Valor Encontrado", "Existe", "Valor Esperado")
|
||||
self.tree_matches = ttk.Treeview(
|
||||
self.frame_res_table,
|
||||
columns=cols_detail,
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
height=5
|
||||
)
|
||||
|
||||
for c in cols_detail:
|
||||
self.tree_matches.heading(c, text=c)
|
||||
self.tree_matches.column(c, width=150)
|
||||
|
||||
sb_detail_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_matches.yview)
|
||||
self.tree_matches.configure(yscrollcommand=sb_detail_y.set)
|
||||
|
||||
self.tree_matches.grid(row=2, column=0, sticky="nsew")
|
||||
sb_detail_y.grid(row=2, column=1, sticky="ns")
|
||||
|
||||
def _create_actions_section(self):
|
||||
"""Crea la sección de acciones."""
|
||||
self.frame_res_actions = ctk.CTkFrame(self.tab_res_preclass)
|
||||
self.frame_res_actions.grid(row=2, column=0, sticky="ew", padx=10, pady=10)
|
||||
|
||||
self.btn_res_export = ctk.CTkButton(
|
||||
self.frame_res_actions,
|
||||
text="Exportar Vista a Excel",
|
||||
fg_color=self.colors["success"]
|
||||
)
|
||||
self.btn_res_export.pack(side="right", padx=10, pady=5)
|
||||
|
||||
self.btn_res_organize = ctk.CTkButton(
|
||||
self.frame_res_actions,
|
||||
text="Organizar Carpetas (Run)",
|
||||
fg_color=self.colors["warning"],
|
||||
hover_color=self.colors["warning_hover"]
|
||||
)
|
||||
self.btn_res_organize.pack(side="right", padx=10, pady=5)
|
||||
|
||||
self.btn_res_refresh = ctk.CTkButton(self.frame_res_actions, text="Refrescar Runs")
|
||||
self.btn_res_refresh.pack(side="left", padx=10, pady=5)
|
||||
|
||||
# --- API Pública ---
|
||||
|
||||
def set_on_search(self, callback):
|
||||
"""Vincula el callback al botón de búsqueda."""
|
||||
self.btn_res_search.configure(command=callback)
|
||||
|
||||
def set_on_export(self, callback):
|
||||
"""Vincula el callback al botón de exportación."""
|
||||
self.btn_res_export.configure(command=callback)
|
||||
|
||||
def set_on_refresh(self, callback):
|
||||
"""Vincula el callback al botón de refrescar."""
|
||||
self.btn_res_refresh.configure(command=callback)
|
||||
|
||||
def set_on_organize(self, callback):
|
||||
"""Vincula el callback al botón de organizar."""
|
||||
self.btn_res_organize.configure(command=callback)
|
||||
|
||||
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."""
|
||||
for item in self.tree_matches.get_children():
|
||||
self.tree_matches.delete(item)
|
||||
|
||||
for r in rows:
|
||||
exists_str = "✅" if r.get("exists_bool") else "❌"
|
||||
vals = (r.get("key"), r.get("found_value"), exists_str, r.get("expected_value") or "-")
|
||||
self.tree_matches.insert("", "end", values=vals)
|
||||
|
||||
def update_runs_combo(self, runs: list):
|
||||
"""Actualiza el combo box con los IDs de ejecuciones disponibles."""
|
||||
vals = ["Todos"] + [str(r) for r in runs]
|
||||
self.combo_res_run.configure(values=vals)
|
||||
self.combo_res_run.set("Todos")
|
||||
|
||||
def set_run_id(self, run_id: int | str):
|
||||
"""Establece el ID de ejecución seleccionado."""
|
||||
try:
|
||||
self.combo_res_run.set(str(run_id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_filters(self) -> dict:
|
||||
"""Devuelve los filtros actuales."""
|
||||
return {
|
||||
"run_id": self.combo_res_run.get(),
|
||||
"status": self.combo_res_status.get(),
|
||||
"text": self.entry_res_search.get(),
|
||||
"relax_on_empty": bool(self._res_relax_var.get()),
|
||||
}
|
||||
|
||||
def set_filter_note(self, text: str) -> None:
|
||||
"""Mensaje bajo ampliar (p. ej. qué criterio se relajó). Texto vacío la limpia."""
|
||||
self.lbl_res_filter_note.configure(text=(text or "").strip())
|
||||
|
||||
def clear_filter_note(self) -> None:
|
||||
self.set_filter_note("")
|
||||
|
||||
def update_results_table(self, rows):
|
||||
"""Actualiza la tabla de resultados con los datos proporcionados."""
|
||||
for item in self.tree_results.get_children():
|
||||
self.tree_results.delete(item)
|
||||
|
||||
for r in rows:
|
||||
vals = (
|
||||
r.get("id"),
|
||||
r.get("run_id"),
|
||||
r.get("timestamp"),
|
||||
r.get("radicado"),
|
||||
r.get("status"),
|
||||
os.path.basename(r.get("file_path") or "")
|
||||
)
|
||||
self.tree_results.insert("", "end", values=vals)
|
||||
@@ -1,314 +0,0 @@
|
||||
"""Pestaña de Configuración de la aplicación GUI."""
|
||||
import customtkinter as ctk
|
||||
from gui.styles import AppStyles
|
||||
from application.app_prefs import (
|
||||
format_moderadora_text_from_prefs,
|
||||
get_allowed_document_types,
|
||||
load_cop_mod_preferences,
|
||||
normalize_document_types,
|
||||
parse_moderadora_text,
|
||||
default_moderadora_by_year_payload,
|
||||
)
|
||||
|
||||
|
||||
class SettingsTab:
|
||||
"""Encapsula la UI de la pestaña Configuración."""
|
||||
|
||||
def __init__(self, parent_tab):
|
||||
"""Inicializa la pestaña de Configuración.
|
||||
|
||||
Args:
|
||||
parent_tab: El CTkFrame que representa la pestaña.
|
||||
"""
|
||||
self.tab = parent_tab
|
||||
self.fonts = AppStyles.get_fonts()
|
||||
self.colors = AppStyles.get_colors()
|
||||
self.spacing = AppStyles.get_spacing()
|
||||
|
||||
self._setup_layout()
|
||||
self._create_api_section()
|
||||
self._create_processing_section()
|
||||
self._create_document_types_section()
|
||||
self._create_cop_mod_section()
|
||||
self._create_save_button()
|
||||
|
||||
def _setup_layout(self):
|
||||
"""Configura el layout: área con scroll y barra inferior fija con Guardar."""
|
||||
self.tab.grid_columnconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(0, weight=1)
|
||||
self.tab.grid_rowconfigure(1, weight=0)
|
||||
|
||||
self.settings_container = ctk.CTkScrollableFrame(
|
||||
self.tab,
|
||||
fg_color="transparent",
|
||||
)
|
||||
self.settings_container.grid(row=0, column=0, sticky="nsew", padx=20, pady=(20, 8))
|
||||
self.settings_container.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.save_footer = ctk.CTkFrame(self.tab, fg_color="transparent")
|
||||
self.save_footer.grid(row=1, column=0, sticky="ew", padx=20, pady=(4, 16))
|
||||
self.save_footer.grid_columnconfigure(0, weight=1)
|
||||
|
||||
def _create_api_section(self):
|
||||
"""Crea la sección de configuración del modelo LLM."""
|
||||
self.frame_api = ctk.CTkFrame(self.settings_container)
|
||||
self.frame_api.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
||||
self.frame_api.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Título
|
||||
lbl_api_title = ctk.CTkLabel(
|
||||
self.frame_api,
|
||||
text="Configuración del Modelo LLM",
|
||||
font=self.fonts["section"]
|
||||
)
|
||||
lbl_api_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10)
|
||||
|
||||
# Selector de modelo
|
||||
lbl_model = ctk.CTkLabel(
|
||||
self.frame_api,
|
||||
text="Modelo (extracción):",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
lbl_model.grid(row=1, column=0, padx=15, pady=10, sticky="w")
|
||||
|
||||
self.combo_model = ctk.CTkComboBox(
|
||||
self.frame_api,
|
||||
values=[
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-4.1-mini",
|
||||
"grok-4.3",
|
||||
],
|
||||
width=250,
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.combo_model.grid(row=1, column=1, padx=15, pady=10, sticky="w")
|
||||
self.combo_model.set("gpt-5.4")
|
||||
|
||||
# Texto de ayuda
|
||||
help_text = (
|
||||
"ℹ gpt-5.4: Modelo predeterminado (OpenAI / compatible)\n"
|
||||
"ℹ gpt-5.4-mini / gpt-4.1-mini: opciones más rápidas\n"
|
||||
"ℹ gpt-5.4-nano: opción más económica\n"
|
||||
"ℹ grok-4.3: API xAI (requiere GROK_API_KEY en .env)"
|
||||
)
|
||||
lbl_help = ctk.CTkLabel(
|
||||
self.frame_api,
|
||||
text=help_text,
|
||||
font=self.fonts["italic_small"],
|
||||
text_color=self.colors["gray_light"],
|
||||
justify="left"
|
||||
)
|
||||
lbl_help.grid(row=2, column=1, padx=15, pady=(0, 10), sticky="w")
|
||||
|
||||
def _create_processing_section(self):
|
||||
"""Crea la sección de procesamiento y rendimiento."""
|
||||
self.frame_proc = ctk.CTkFrame(self.settings_container)
|
||||
self.frame_proc.grid(row=1, column=0, sticky="ew", padx=5, pady=5)
|
||||
self.frame_proc.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Título
|
||||
lbl_proc_title = ctk.CTkLabel(
|
||||
self.frame_proc,
|
||||
text="Procesamiento y Rendimiento",
|
||||
font=self.fonts["section"]
|
||||
)
|
||||
lbl_proc_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10)
|
||||
|
||||
# Límite de concurrencia
|
||||
lbl_conc = ctk.CTkLabel(
|
||||
self.frame_proc,
|
||||
text="Límite de Concurrencia:",
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
lbl_conc.grid(row=1, column=0, padx=15, pady=10, sticky="w")
|
||||
|
||||
self.concurrency_var = ctk.StringVar(value="100")
|
||||
self.concurrency_entry = ctk.CTkEntry(
|
||||
self.frame_proc,
|
||||
textvariable=self.concurrency_var,
|
||||
width=100,
|
||||
font=self.fonts["ui"]
|
||||
)
|
||||
self.concurrency_entry.grid(row=1, column=1, padx=15, pady=10, sticky="w")
|
||||
|
||||
# Advertencia
|
||||
warning_text = (
|
||||
"⚠️ Advertencia: Entre mayor el número, mayor la probabilidad de Rate Limits (429)."
|
||||
)
|
||||
lbl_conc_warn = ctk.CTkLabel(
|
||||
self.frame_proc,
|
||||
text=warning_text,
|
||||
font=self.fonts["italic_small"],
|
||||
text_color="orange"
|
||||
)
|
||||
lbl_conc_warn.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 10), sticky="w")
|
||||
|
||||
def _create_document_types_section(self):
|
||||
"""Crea la sección editable de tipos documentales permitidos."""
|
||||
self.frame_doc_types = ctk.CTkFrame(self.settings_container)
|
||||
self.frame_doc_types.grid(row=2, column=0, sticky="ew", padx=5, pady=5)
|
||||
self.frame_doc_types.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl_title = ctk.CTkLabel(
|
||||
self.frame_doc_types,
|
||||
text="Tipos de Documento Permitidos",
|
||||
font=self.fonts["section"]
|
||||
)
|
||||
lbl_title.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 4))
|
||||
|
||||
lbl_help = ctk.CTkLabel(
|
||||
self.frame_doc_types,
|
||||
text="Escríbelos separados por comas. Se usarán en documento_forzate. NIT se ignorará automáticamente.",
|
||||
font=self.fonts["italic_small"],
|
||||
text_color=self.colors["gray_light"],
|
||||
justify="left"
|
||||
)
|
||||
lbl_help.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 6))
|
||||
|
||||
self.allowed_doc_types_entry = ctk.CTkEntry(
|
||||
self.frame_doc_types,
|
||||
font=self.fonts["ui"],
|
||||
placeholder_text="CC, TI, CE, RC, PA, PPT, PEP, EPT, NUIP"
|
||||
)
|
||||
self.allowed_doc_types_entry.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 10))
|
||||
self.set_allowed_document_types(list(get_allowed_document_types()))
|
||||
|
||||
def _create_cop_mod_section(self):
|
||||
"""Cuotas moderadora por año (regla Separa COP/MOD)."""
|
||||
self.frame_cop_mod = ctk.CTkFrame(self.settings_container)
|
||||
self.frame_cop_mod.grid(row=3, column=0, sticky="ew", padx=5, pady=5)
|
||||
self.frame_cop_mod.grid_columnconfigure(0, weight=1)
|
||||
|
||||
lbl_title = ctk.CTkLabel(
|
||||
self.frame_cop_mod,
|
||||
text="Cuota moderadora (COP/MOD)",
|
||||
font=self.fonts["section"],
|
||||
)
|
||||
lbl_title.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 4))
|
||||
|
||||
lbl_help = ctk.CTkLabel(
|
||||
self.frame_cop_mod,
|
||||
text=(
|
||||
"Una línea por año: 2024: 4500, 18200, 47700. "
|
||||
"La columna «Fecha Servicio» se elige en Proceso de Limpieza al activar la regla."
|
||||
),
|
||||
font=self.fonts["italic_small"],
|
||||
text_color=self.colors["gray_light"],
|
||||
justify="left",
|
||||
)
|
||||
lbl_help.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 6))
|
||||
|
||||
merged, fecha_def = load_cop_mod_preferences()
|
||||
initial_txt = format_moderadora_text_from_prefs(merged)
|
||||
|
||||
self.moderadora_text = ctk.CTkTextbox(
|
||||
self.frame_cop_mod,
|
||||
font=self.fonts["mono_small"],
|
||||
height=140,
|
||||
)
|
||||
self.moderadora_text.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 8))
|
||||
self.moderadora_text.insert("1.0", initial_txt)
|
||||
|
||||
row_fe = ctk.CTkFrame(self.frame_cop_mod, fg_color="transparent")
|
||||
row_fe.grid(row=3, column=0, sticky="ew", padx=15, pady=(0, 6))
|
||||
row_fe.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(
|
||||
row_fe,
|
||||
text="Sugerencia nombre columna fecha (opcional):",
|
||||
font=self.fonts["ui"],
|
||||
).grid(row=0, column=0, padx=(0, 10), sticky="w")
|
||||
|
||||
self.fecha_column_default_var = ctk.StringVar(value=fecha_def)
|
||||
self.fecha_column_default_entry = ctk.CTkEntry(
|
||||
row_fe,
|
||||
textvariable=self.fecha_column_default_var,
|
||||
font=self.fonts["ui"],
|
||||
placeholder_text="Fecha Servicio",
|
||||
)
|
||||
self.fecha_column_default_entry.grid(row=0, column=1, sticky="ew")
|
||||
|
||||
self.btn_moderadora_defaults = ctk.CTkButton(
|
||||
self.frame_cop_mod,
|
||||
text="Restaurar valores por defecto",
|
||||
font=self.fonts["ui"],
|
||||
command=self._on_restore_moderadora_defaults,
|
||||
)
|
||||
self.btn_moderadora_defaults.grid(row=4, column=0, sticky="w", padx=15, pady=(0, 12))
|
||||
|
||||
def _on_restore_moderadora_defaults(self) -> None:
|
||||
txt = format_moderadora_text_from_prefs(default_moderadora_by_year_payload())
|
||||
self.moderadora_text.delete("1.0", "end")
|
||||
self.moderadora_text.insert("1.0", txt)
|
||||
|
||||
def _create_save_button(self):
|
||||
"""Barra inferior fija: siempre visible aunque el formulario sea largo."""
|
||||
self.btn_save_settings = ctk.CTkButton(
|
||||
self.save_footer,
|
||||
text="Guardar cambios",
|
||||
font=self.fonts["ui"],
|
||||
height=38,
|
||||
)
|
||||
self.btn_save_settings.grid(row=0, column=0, sticky="ew")
|
||||
|
||||
# --- API Pública ---
|
||||
|
||||
def set_on_save(self, callback):
|
||||
"""Vincula el callback al botón de guardar."""
|
||||
self.btn_save_settings.configure(command=callback)
|
||||
|
||||
def get_selected_model(self) -> str:
|
||||
"""Devuelve el modelo seleccionado."""
|
||||
return self.combo_model.get()
|
||||
|
||||
def set_selected_model(self, model: str) -> None:
|
||||
"""Establece el modelo seleccionado."""
|
||||
self.combo_model.set(model)
|
||||
|
||||
def get_concurrency_limit(self) -> int:
|
||||
"""Devuelve el límite de concurrencia."""
|
||||
try:
|
||||
return int(self.concurrency_var.get())
|
||||
except Exception:
|
||||
return 20
|
||||
|
||||
def set_concurrency_limit(self, limit: int) -> None:
|
||||
"""Establece el límite de concurrencia."""
|
||||
self.concurrency_var.set(str(limit))
|
||||
|
||||
def get_allowed_document_types(self) -> list[str]:
|
||||
"""Devuelve la lista editable de tipos documentales válidos."""
|
||||
raw = self.allowed_doc_types_entry.get().strip()
|
||||
values = [part.strip() for part in raw.split(",")]
|
||||
return normalize_document_types(values)
|
||||
|
||||
def set_allowed_document_types(self, values: list[str]) -> None:
|
||||
"""Establece la lista de tipos documentales válidos."""
|
||||
self.allowed_doc_types_entry.delete(0, "end")
|
||||
self.allowed_doc_types_entry.insert(0, ", ".join(normalize_document_types(values)))
|
||||
|
||||
def get_moderadora_cuotas_text(self) -> str:
|
||||
"""Texto multilínea año: valores."""
|
||||
return self.moderadora_text.get("1.0", "end").strip()
|
||||
|
||||
def set_moderadora_cuotas_ui(self, payload: dict[str, list[int]], fecha_hint: str) -> None:
|
||||
"""Establece cuotas y sugerencia de columna fecha desde prefs."""
|
||||
self.moderadora_text.delete("1.0", "end")
|
||||
self.moderadora_text.insert("1.0", format_moderadora_text_from_prefs(payload))
|
||||
self.fecha_column_default_var.set(fecha_hint or "")
|
||||
|
||||
def get_fecha_servicio_column_hint(self) -> str:
|
||||
return str(self.fecha_column_default_var.get() or "").strip()
|
||||
|
||||
def enable_controls(self, enabled: bool):
|
||||
"""Habilita o deshabilita los controles."""
|
||||
state = "normal" if enabled else "disabled"
|
||||
self.combo_model.configure(state=state)
|
||||
self.concurrency_entry.configure(state=state)
|
||||
self.allowed_doc_types_entry.configure(state=state)
|
||||
self.moderadora_text.configure(state=state)
|
||||
self.fecha_column_default_entry.configure(state=state)
|
||||
self.btn_moderadora_defaults.configure(state=state)
|
||||
self.btn_save_settings.configure(state=state)
|
||||
-418
@@ -1,418 +0,0 @@
|
||||
"""Vista principal de la aplicación GUI con `customtkinter`.
|
||||
|
||||
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
|
||||
import customtkinter as ctk
|
||||
from gui.styles import AppStyles
|
||||
from gui.tabs import SettingsTab, ResultsTab, MainProcessingTab, PreclassificationTab, MetricsTab, PostProcessTab, CleaningTab
|
||||
|
||||
|
||||
class InvoiceAppView(ctk.CTk):
|
||||
"""Ventana principal con pestañas de Procesar y Configuración."""
|
||||
def __init__(self):
|
||||
"""Construye la ventana principal y sus pestañas/controles."""
|
||||
super().__init__()
|
||||
self.title("IPSExtractor - Procesador de Documentos")
|
||||
self.geometry("1100x800")
|
||||
|
||||
# Configurar tema
|
||||
AppStyles.configure_theme()
|
||||
|
||||
# Obtener estilos
|
||||
self.fonts = AppStyles.get_fonts()
|
||||
self.colors = AppStyles.get_colors()
|
||||
|
||||
# Retrocompatibilidad: usar los nombres antiguos
|
||||
self.font_title = self.fonts["title"]
|
||||
self.font_section = self.fonts["section"]
|
||||
self.font_ui = self.fonts["ui"]
|
||||
self.font_mono = self.fonts["mono"]
|
||||
|
||||
# Layout principal
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
|
||||
# TabView principal con estilo
|
||||
self.tabs = ctk.CTkTabview(self, width=1000, height=700)
|
||||
self.tabs.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
|
||||
|
||||
self.tab_main = self.tabs.add("Procesar Documentos")
|
||||
self.tab_cleaning = self.tabs.add("Proceso de Limpieza")
|
||||
self.tab_preclass = self.tabs.add("Preclasificación")
|
||||
self.tab_results = self.tabs.add("Resultados")
|
||||
self.tab_settings = self.tabs.add("Configuración")
|
||||
self.tab_usage = self.tabs.add("Dashboard & Métricas")
|
||||
self.tab_postprocess = self.tabs.add("Post-Procesamiento")
|
||||
|
||||
# =========================================================================
|
||||
# --- TAB: PROCESAR DOCUMENTOS ---
|
||||
# =========================================================================
|
||||
self.main_tab = MainProcessingTab(self.tab_main)
|
||||
|
||||
# =========================================================================
|
||||
# --- TAB: PROCESO DE LIMPIEZA ---
|
||||
# =========================================================================
|
||||
self.cleaning_tab = CleaningTab(self.tab_cleaning)
|
||||
|
||||
# =========================================================================
|
||||
# --- TAB: PRECLASIFICACIÓN ---
|
||||
# =========================================================================
|
||||
self.preclass_tab = PreclassificationTab(self.tab_preclass)
|
||||
|
||||
# =========================================================================
|
||||
# --- TAB: RESULTADOS ---
|
||||
# =========================================================================
|
||||
self.results_tab = ResultsTab(self.tab_results)
|
||||
|
||||
# =========================================================================
|
||||
# --- TAB: CONFIGURACIÓN ---
|
||||
# =========================================================================
|
||||
self.settings_tab = SettingsTab(self.tab_settings)
|
||||
|
||||
# =========================================================================
|
||||
# --- TAB: DASHBOARD & MÉTRICAS ---
|
||||
# =========================================================================
|
||||
self.metrics_tab = MetricsTab(self.tab_usage)
|
||||
|
||||
# =========================================================================
|
||||
# --- TAB: POST-PROCESAMIENTO ---
|
||||
# =========================================================================
|
||||
self.postprocess_tab = PostProcessTab(self.tab_postprocess)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MÉTODOS PÚBLICOS (API COMPATIBLE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_on_select_root(self, callback):
|
||||
self.main_tab.set_on_select_root(callback)
|
||||
|
||||
def set_on_select_preclass_root(self, callback):
|
||||
self.preclass_tab.set_on_select_root(callback)
|
||||
|
||||
def set_on_process(self, callback):
|
||||
self.main_tab.set_on_process(callback)
|
||||
|
||||
def set_on_select_csv(self, callback):
|
||||
self.main_tab.set_on_select_csv(callback)
|
||||
|
||||
def set_on_save_settings(self, callback):
|
||||
self.settings_tab.set_on_save(callback)
|
||||
|
||||
def get_selected_model(self) -> str:
|
||||
return self.settings_tab.get_selected_model()
|
||||
|
||||
def set_selected_model(self, model: str) -> None:
|
||||
self.settings_tab.set_selected_model(model)
|
||||
|
||||
def get_concurrency_limit(self) -> int:
|
||||
return self.settings_tab.get_concurrency_limit()
|
||||
|
||||
def set_concurrency_limit(self, limit: int) -> None:
|
||||
self.settings_tab.set_concurrency_limit(limit)
|
||||
|
||||
def get_allowed_document_types(self) -> list[str]:
|
||||
return self.settings_tab.get_allowed_document_types()
|
||||
|
||||
def set_allowed_document_types(self, values: list[str]) -> None:
|
||||
self.settings_tab.set_allowed_document_types(values)
|
||||
|
||||
def get_moderadora_cuotas_text(self) -> str:
|
||||
return self.settings_tab.get_moderadora_cuotas_text()
|
||||
|
||||
def set_moderadora_cuotas_ui(self, payload: dict, fecha_hint: str) -> None:
|
||||
self.settings_tab.set_moderadora_cuotas_ui(payload, fecha_hint)
|
||||
|
||||
def get_fecha_servicio_column_hint(self) -> str:
|
||||
return self.settings_tab.get_fecha_servicio_column_hint()
|
||||
|
||||
def set_on_run_preclassification(self, callback):
|
||||
self.preclass_tab.set_on_run(callback)
|
||||
|
||||
def set_on_export_excel(self, callback):
|
||||
self.preclass_tab.set_on_export_excel(callback)
|
||||
|
||||
def set_on_organize_files(self, callback):
|
||||
self.preclass_tab.set_on_organize_files(callback)
|
||||
|
||||
def enable_preclass_actions(self, enabled: bool):
|
||||
self.preclass_tab.enable_actions(enabled)
|
||||
|
||||
# --- Métodos Resultados ---
|
||||
def set_on_res_search(self, callback):
|
||||
self.results_tab.set_on_search(callback)
|
||||
|
||||
def set_on_res_export(self, callback):
|
||||
self.results_tab.set_on_export(callback)
|
||||
|
||||
def set_on_res_refresh(self, callback):
|
||||
self.results_tab.set_on_refresh(callback)
|
||||
|
||||
def set_on_res_organize(self, callback):
|
||||
self.results_tab.set_on_organize(callback)
|
||||
|
||||
def set_on_res_select(self, callback):
|
||||
self.results_tab.set_on_select_result(callback)
|
||||
|
||||
def update_res_matches_table(self, rows):
|
||||
self.results_tab.update_matches_table(rows)
|
||||
|
||||
def update_res_runs_combo(self, runs: list):
|
||||
self.results_tab.update_runs_combo(runs)
|
||||
|
||||
def set_res_run(self, run_id: int | str):
|
||||
self.results_tab.set_run_id(run_id)
|
||||
|
||||
def get_res_filters(self):
|
||||
return self.results_tab.get_filters()
|
||||
|
||||
def set_res_filter_note(self, text: str) -> None:
|
||||
self.results_tab.set_filter_note(text)
|
||||
|
||||
def clear_res_filter_note(self) -> None:
|
||||
self.results_tab.clear_filter_note()
|
||||
|
||||
def update_res_table(self, rows):
|
||||
self.results_tab.update_results_table(rows)
|
||||
|
||||
def append_preclass_output(self, text: str) -> None:
|
||||
self.preclass_tab.append_output(text)
|
||||
|
||||
def append_preclass_output_safe(self, text: str) -> None:
|
||||
self.after(0, lambda: self.preclass_tab.append_output_safe(text))
|
||||
|
||||
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()
|
||||
|
||||
def set_process_mode(self, mode: str) -> None:
|
||||
self.main_tab.set_process_mode(mode)
|
||||
|
||||
def get_preclass_root_path(self) -> str:
|
||||
return self.preclass_tab.get_root_path()
|
||||
|
||||
def get_preclass_mode(self) -> str:
|
||||
"""Devuelve el modo de preclasificación: 'radicados' o 'general'."""
|
||||
return self.preclass_tab.get_mode()
|
||||
|
||||
def get_preclass_range(self) -> tuple[bool, int, int]:
|
||||
"""Devuelve (habilitado, desde, hasta) para preclasificación."""
|
||||
return self.preclass_tab.get_range()
|
||||
|
||||
def get_preclass_target_info(self) -> dict:
|
||||
"""Devuelve la configuración del archivo objetivo."""
|
||||
return self.preclass_tab.get_target_info()
|
||||
|
||||
def get_keywords(self) -> str:
|
||||
return self.main_tab.get_keywords()
|
||||
|
||||
def get_fields(self) -> str:
|
||||
return self.main_tab.get_fields()
|
||||
|
||||
def get_csv_path(self) -> str:
|
||||
return self.main_tab.get_csv_path()
|
||||
|
||||
def get_retry_empty_enabled(self) -> bool:
|
||||
return self.main_tab.get_retry_empty_enabled()
|
||||
|
||||
def set_retry_empty_enabled(self, enabled: bool) -> None:
|
||||
self.main_tab.set_retry_empty_enabled(enabled)
|
||||
|
||||
def get_radicado_filter_enabled(self) -> bool:
|
||||
return self.main_tab.get_radicado_filter_enabled()
|
||||
|
||||
def get_radicado_filter_list(self) -> list[str]:
|
||||
return self.main_tab.get_radicado_filter_list()
|
||||
|
||||
def show_tiff_range_controls(self, show: bool) -> None:
|
||||
self.main_tab.show_tiff_range_controls(show)
|
||||
|
||||
def get_tiff_start_page(self) -> int:
|
||||
return self.main_tab.get_tiff_start_page()
|
||||
|
||||
def get_tiff_end_page(self) -> int:
|
||||
return self.main_tab.get_tiff_end_page()
|
||||
|
||||
def append_output(self, text: str) -> None:
|
||||
self.main_tab.append_output(text)
|
||||
|
||||
def append_output_safe(self, text: str) -> None:
|
||||
self.after(0, lambda: self.main_tab.append_output_safe(text))
|
||||
|
||||
def set_processing(self, processing: bool) -> None:
|
||||
self.main_tab.enable_controls(not processing)
|
||||
|
||||
|
||||
# --- TAB PRECLASIFICACIÓN ---
|
||||
try:
|
||||
# Actualizar controles de la pestaña de preclasificación
|
||||
self.preclass_tab.enable_controls(not processing)
|
||||
|
||||
# Actualizar controles de la pestaña de configuración
|
||||
self.settings_tab.enable_controls(not processing)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- TAB USO ---
|
||||
|
||||
def append_usage(self, text: str) -> None:
|
||||
self.metrics_tab.append_usage(text)
|
||||
|
||||
def append_usage_safe(self, text: str) -> None:
|
||||
self.after(0, lambda: self.metrics_tab.append_usage_safe(text))
|
||||
|
||||
def clear_usage(self) -> None:
|
||||
self.metrics_tab.clear()
|
||||
|
||||
def set_usage_totals(self, **kwargs):
|
||||
self.metrics_tab.set_usage_totals(**kwargs)
|
||||
|
||||
def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None:
|
||||
self.metrics_tab.update_kpis(overall, extract_phase, normalize_phase)
|
||||
|
||||
def update_doc_kpi(self, processed: int, avg_cost: float) -> None:
|
||||
self.metrics_tab.update_doc_kpi(processed, avg_cost)
|
||||
|
||||
def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None:
|
||||
self.metrics_tab.update_cost_line(doc_index, total_cost_usd)
|
||||
|
||||
def update_token_bars(self, input_extract: int, cached_extract: int, output_extract: int,
|
||||
input_norm: int, cached_norm: int, output_norm: int,
|
||||
input_pp: int = 0, output_pp: int = 0) -> None:
|
||||
self.metrics_tab.update_token_bars(input_extract, cached_extract, output_extract,
|
||||
input_norm, cached_norm, output_norm,
|
||||
input_pp, output_pp)
|
||||
|
||||
def update_comparison_table(self, comparisons: list[dict]) -> None:
|
||||
self.metrics_tab.update_comparison_table(comparisons)
|
||||
|
||||
def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None:
|
||||
self.metrics_tab.append_usage_row(phase, radicado, usage, cost)
|
||||
|
||||
def update_pp_kpis(self, total_usd: float, total_calls: int, total_retries: int,
|
||||
input_tokens: int, output_tokens: int, last_description: str) -> None:
|
||||
self.metrics_tab.update_pp_kpis(total_usd, total_calls, total_retries,
|
||||
input_tokens, output_tokens, last_description)
|
||||
|
||||
# --- HELPERS INTERNOS ---
|
||||
|
||||
def get_preclass_patterns(self) -> list[dict]:
|
||||
"""Devuelve la lista de patrones de preclasificación definida en la pestaña."""
|
||||
return self.preclass_tab.get_patterns()
|
||||
|
||||
# --- TAB POST-PROCESAMIENTO ---
|
||||
|
||||
def set_on_postprocess_select_csv(self, callback):
|
||||
self.postprocess_tab.set_on_select_csv(callback)
|
||||
|
||||
def set_on_postprocess_run(self, callback):
|
||||
self.postprocess_tab.set_on_run(callback)
|
||||
|
||||
def set_on_postprocess_save(self, callback):
|
||||
self.postprocess_tab.set_on_save(callback)
|
||||
|
||||
def get_postprocess_instructions(self) -> str:
|
||||
return self.postprocess_tab.get_instructions()
|
||||
|
||||
def set_postprocess_csv_path(self, path: str):
|
||||
self.postprocess_tab.set_csv_path(path)
|
||||
|
||||
def set_postprocess_preview(self, text: str):
|
||||
self.postprocess_tab.set_preview(text)
|
||||
|
||||
def set_postprocess_code(self, text: str):
|
||||
self.postprocess_tab.set_code(text)
|
||||
|
||||
def append_postprocess_log(self, text: str):
|
||||
self.postprocess_tab.append_log(text)
|
||||
|
||||
def append_postprocess_log_safe(self, text: str):
|
||||
self.after(0, lambda: self.postprocess_tab.append_log(text))
|
||||
|
||||
def clear_postprocess_log(self):
|
||||
self.postprocess_tab.clear_log()
|
||||
|
||||
def set_postprocess_result(self, text: str):
|
||||
self.postprocess_tab.set_result(text)
|
||||
|
||||
def set_postprocess_status(self, text: str):
|
||||
self.postprocess_tab.set_status(text)
|
||||
|
||||
def enable_postprocess_save(self, enabled: bool):
|
||||
self.postprocess_tab.enable_save(enabled)
|
||||
|
||||
def enable_postprocess_controls(self, enabled: bool):
|
||||
self.postprocess_tab.enable_controls(enabled)
|
||||
|
||||
def get_doc_types(self) -> list[dict]:
|
||||
return self.main_tab.get_doc_types()
|
||||
|
||||
def set_doc_types(self, doc_types: list[dict]) -> None:
|
||||
self.main_tab.set_doc_types(doc_types)
|
||||
|
||||
def set_allowed_document_types_hint(self, allowed_types: list[str]) -> None:
|
||||
self.main_tab.set_allowed_document_types_hint(allowed_types)
|
||||
|
||||
def validate_doc_types(self) -> tuple[bool, str | None]:
|
||||
return self.main_tab.validate_doc_types()
|
||||
|
||||
# --- TAB PROCESO DE LIMPIEZA ---
|
||||
|
||||
def set_on_cleaning_select_csv(self, callback):
|
||||
self.cleaning_tab.set_on_select_csv(callback)
|
||||
|
||||
def set_on_cleaning_apply(self, callback):
|
||||
self.cleaning_tab.set_on_apply(callback)
|
||||
|
||||
def set_on_cleaning_save(self, callback):
|
||||
self.cleaning_tab.set_on_save(callback)
|
||||
|
||||
def set_cleaning_csv_path(self, path: str):
|
||||
self.cleaning_tab.set_csv_path(path)
|
||||
|
||||
def set_cleaning_preview(self, df):
|
||||
self.cleaning_tab.set_preview(df)
|
||||
|
||||
def populate_cleaning_columns(self, summaries):
|
||||
self.cleaning_tab.populate_columns(summaries)
|
||||
|
||||
def get_cleaning_selected_rules(self) -> dict[str, list[str]]:
|
||||
return self.cleaning_tab.get_selected_rules()
|
||||
|
||||
def get_cleaning_cop_mod_fecha_columns(self) -> dict[str, str]:
|
||||
return self.cleaning_tab.get_cop_mod_fecha_columns()
|
||||
|
||||
def set_cleaning_result(self, df, info_text: str = ""):
|
||||
self.cleaning_tab.set_result(df, info_text)
|
||||
|
||||
def enable_cleaning_save(self, enabled: bool):
|
||||
self.cleaning_tab.enable_save(enabled)
|
||||
|
||||
def enable_cleaning_controls(self, enabled: bool):
|
||||
self.cleaning_tab.enable_controls(enabled)
|
||||
|
||||
def set_cleaning_status(self, text: str):
|
||||
self.cleaning_tab.set_status(text)
|
||||
|
||||
def set_cleaning_loading(self, loading: bool, text: str = ""):
|
||||
self.cleaning_tab.set_loading(loading, text)
|
||||
|
||||
def set_cleaning_delimiter_chip(self, delimiter: str):
|
||||
self.cleaning_tab.set_delimiter_chip(delimiter)
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
"""Entrada para la interfaz gráfica.
|
||||
|
||||
Crea el servicio con el extractor por defecto y lanza la ventana principal.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from application.service import InvoiceProcessingService
|
||||
from application.strategies import OllamaExtractor
|
||||
from gui.view import InvoiceAppView
|
||||
from gui.controller import AppController
|
||||
from application.logging_config import configure_logging
|
||||
from application.config import settings
|
||||
|
||||
|
||||
def main():
|
||||
"""Inicializa logging y arranca la aplicación GUI."""
|
||||
configure_logging()
|
||||
|
||||
# Log effective settings *after* logging is configured
|
||||
_cfg_logger = logging.getLogger("application.config")
|
||||
try:
|
||||
_cfg_logger.info(
|
||||
"effective_api_settings",
|
||||
extra={
|
||||
"component": "config",
|
||||
"base_url": settings.api.base_url,
|
||||
"model": settings.api.model,
|
||||
"timeout_s": getattr(settings.api, "request_timeout_s", None),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
extractor = OllamaExtractor(
|
||||
api_key=settings.api.api_key,
|
||||
base_url=settings.api.base_url,
|
||||
model=settings.api.model
|
||||
)
|
||||
|
||||
from application.batch_processor import BatchProcessor
|
||||
from application.estimator import Estimator
|
||||
|
||||
service = InvoiceProcessingService(
|
||||
extractor,
|
||||
batch_processor_factory=lambda s: BatchProcessor(s),
|
||||
estimator_factory=lambda s: Estimator(s)
|
||||
)
|
||||
|
||||
app = InvoiceAppView()
|
||||
AppController(app, service)
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
; ============================================================================
|
||||
; Inno Setup Script para IPSExtractor
|
||||
;
|
||||
; Prerequisito: ejecutar build_installer.bat primero para generar dist\IPSExtractor
|
||||
; Luego: build_installer.bat --inno o compilar este .iss directamente
|
||||
;
|
||||
; Descargar Inno Setup: https://jrsoftware.org/isdl.php
|
||||
; ============================================================================
|
||||
|
||||
#define MyAppName "IPSExtractor"
|
||||
#define MyAppVersion "1.0.0"
|
||||
#define MyAppPublisher "IPSExtractor"
|
||||
#define MyAppExeName "IPSExtractor.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=dist
|
||||
OutputBaseFilename=IPSExtractor_Setup_{#MyAppVersion}
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
SetupLogging=yes
|
||||
|
||||
[Languages]
|
||||
Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Crear acceso directo en el Escritorio"; GroupDescription: "Accesos directos:"
|
||||
|
||||
[Files]
|
||||
; Todos los archivos de dist\IPSExtractor
|
||||
Source: "dist\IPSExtractor\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{group}\Desinstalar {#MyAppName}"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "Ejecutar {#MyAppName}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -1,51 +0,0 @@
|
||||
annotated-types==0.7.0
|
||||
anyio==4.11.0
|
||||
certifi==2025.8.3
|
||||
charset-normalizer==3.4.3
|
||||
colorama==0.4.6
|
||||
coloredlogs==15.0.1
|
||||
contourpy==1.3.3
|
||||
customtkinter==5.2.2
|
||||
cycler==0.12.1
|
||||
darkdetect==0.8.0
|
||||
distro==1.9.0
|
||||
flatbuffers==25.9.23
|
||||
fonttools==4.60.1
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
humanfriendly==10.0
|
||||
idna==3.10
|
||||
jiter==0.11.0
|
||||
kiwisolver==1.4.9
|
||||
matplotlib==3.10.7
|
||||
mpmath==1.3.0
|
||||
numpy==2.2.6
|
||||
ollama==0.6.0
|
||||
onnxruntime==1.23.2
|
||||
openai==2.1.0
|
||||
opencv-python==4.12.0.88
|
||||
packaging==25.0
|
||||
pdf2image==1.17.0
|
||||
pillow==11.3.0
|
||||
protobuf==6.33.0
|
||||
pyclipper==1.3.0.post6
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.11.0
|
||||
pydantic_core==2.33.2
|
||||
pyparsing==3.2.5
|
||||
pyreadline3==3.5.4
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.1
|
||||
PyYAML==6.0.3
|
||||
rapidocr-onnxruntime==1.4.4
|
||||
requests==2.32.5
|
||||
shapely==2.1.2
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
sympy==1.14.0
|
||||
tenacity==9.1.2
|
||||
tqdm==4.67.1
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.5.0
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Capa de persistencia SQLite para el servidor de tracking.
|
||||
|
||||
Guarda el historial completo de lotes de extracción, incluyendo
|
||||
métricas de progreso, costos y la ruta del CSV generado.
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "tracking.db")
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Crea las tablas si no existen."""
|
||||
with get_connection() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS batch_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
radicado TEXT,
|
||||
message TEXT NOT NULL,
|
||||
FOREIGN KEY (batch_id) REFERENCES batches(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_logs_batch_id ON batch_logs(batch_id)")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
root_path TEXT NOT NULL,
|
||||
csv_output_path TEXT,
|
||||
hostname TEXT,
|
||||
username TEXT,
|
||||
total_radicados INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'processing',
|
||||
current_radicado_index INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd REAL NOT NULL DEFAULT 0.0,
|
||||
last_error TEXT,
|
||||
current_phase TEXT,
|
||||
current_radicado TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def insert_batch(batch: Dict[str, Any]):
|
||||
"""Inserta un nuevo lote en la base de datos."""
|
||||
with get_connection() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO batches (
|
||||
id, root_path, csv_output_path, hostname, username, total_radicados, config,
|
||||
status, current_radicado_index, success_count, error_count,
|
||||
total_tokens, total_cost_usd, last_error, current_phase, current_radicado,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
:id, :root_path, :csv_output_path, :hostname, :username, :total_radicados, :config,
|
||||
:status, :current_radicado_index, :success_count, :error_count,
|
||||
:total_tokens, :total_cost_usd, :last_error, :current_phase, :current_radicado,
|
||||
:created_at, :updated_at
|
||||
)
|
||||
""", {
|
||||
**batch,
|
||||
"config": json.dumps(batch.get("config") or {}),
|
||||
"created_at": _to_iso(batch.get("created_at")),
|
||||
"updated_at": _to_iso(batch.get("updated_at")),
|
||||
"current_phase": batch.get("current_phase"),
|
||||
"current_radicado": batch.get("current_radicado"),
|
||||
})
|
||||
conn.commit()
|
||||
|
||||
|
||||
def update_batch(batch_id: str, fields: Dict[str, Any]):
|
||||
"""Actualiza campos de un lote existente."""
|
||||
if not fields:
|
||||
return
|
||||
fields["updated_at"] = _to_iso(datetime.now())
|
||||
set_clause = ", ".join(f"{k} = :{k}" for k in fields)
|
||||
fields["batch_id"] = batch_id
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE batches SET {set_clause} WHERE id = :batch_id",
|
||||
fields
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_all_batches() -> List[Dict[str, Any]]:
|
||||
"""Retorna todos los lotes ordenados por fecha de creación descendente."""
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM batches ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def insert_log(batch_id: str, message: str, level: str = "info", radicado: str = None):
|
||||
"""Inserta una entrada de log para un lote."""
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO batch_logs (batch_id, timestamp, level, radicado, message) VALUES (?, ?, ?, ?, ?)",
|
||||
(batch_id, datetime.now().isoformat(), level, radicado, message)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_logs(batch_id: str, limit: int = 200) -> List[Dict[str, Any]]:
|
||||
"""Retorna los logs de un lote, del más reciente al más antiguo."""
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM batch_logs WHERE batch_id = ? ORDER BY id DESC LIMIT ?",
|
||||
(batch_id, limit)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_batch(batch_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Retorna un lote por su ID."""
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM batches WHERE id = ?", (batch_id,)
|
||||
).fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
d = dict(row)
|
||||
try:
|
||||
d["config"] = json.loads(d.get("config") or "{}")
|
||||
except Exception:
|
||||
d["config"] = {}
|
||||
|
||||
# Asegurar que hostname y username estén presentes
|
||||
if "hostname" not in d: d["hostname"] = None
|
||||
if "username" not in d: d["username"] = None
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def _to_iso(value) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return datetime.now().isoformat()
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Servidor de tracking de IPSExtractor.
|
||||
|
||||
Persiste el historial de lotes en SQLite y distribuye actualizaciones
|
||||
en tiempo real vía WebSockets al dashboard web.
|
||||
"""
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, List, Any, Optional
|
||||
from pydantic import BaseModel
|
||||
import uuid
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from .schemas.batch import BatchCreate, BatchUpdate, BatchRead, BatchStatus
|
||||
from .database import init_db, insert_batch, update_batch, get_all_batches, get_batch, insert_log, get_logs
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="IPSExtractor Tracking API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Cache en memoria para lotes activos (para broadcast rápido)
|
||||
active_batches: Dict[str, Dict[str, Any]] = {}
|
||||
active_connections: List[WebSocket] = []
|
||||
|
||||
|
||||
async def broadcast(message: dict):
|
||||
json_compatible_message = jsonable_encoder(message)
|
||||
disconnected = []
|
||||
for connection in active_connections:
|
||||
try:
|
||||
await connection.send_json(json_compatible_message)
|
||||
except Exception:
|
||||
disconnected.append(connection)
|
||||
for conn in disconnected:
|
||||
active_connections.remove(conn)
|
||||
|
||||
|
||||
@app.post("/batches", response_model=BatchRead)
|
||||
async def create_batch(batch: BatchCreate):
|
||||
batch_id = str(uuid.uuid4())
|
||||
now = datetime.now()
|
||||
new_batch = {
|
||||
"id": batch_id,
|
||||
**batch.model_dump(),
|
||||
"status": BatchStatus.PROCESSING,
|
||||
"current_radicado_index": 0,
|
||||
"success_count": 0,
|
||||
"error_count": 0,
|
||||
"total_tokens": 0,
|
||||
"total_cost_usd": 0.0,
|
||||
"last_error": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
active_batches[batch_id] = new_batch
|
||||
insert_batch(new_batch)
|
||||
await broadcast({"event": "batch_created", "data": new_batch})
|
||||
return new_batch
|
||||
|
||||
|
||||
@app.get("/batches", response_model=List[BatchRead])
|
||||
async def list_batches():
|
||||
# Retorna historial completo desde SQLite
|
||||
db_batches = get_all_batches()
|
||||
# Sobreescribe con datos en memoria si el lote está activo (más fresco)
|
||||
result = []
|
||||
for b in db_batches:
|
||||
if b["id"] in active_batches:
|
||||
result.append(active_batches[b["id"]])
|
||||
else:
|
||||
result.append(b)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/batches/{batch_id}", response_model=BatchRead)
|
||||
async def get_batch_endpoint(batch_id: str):
|
||||
if batch_id in active_batches:
|
||||
return active_batches[batch_id]
|
||||
batch = get_batch(batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="Lote no encontrado")
|
||||
return batch
|
||||
|
||||
|
||||
@app.patch("/batches/{batch_id}", response_model=BatchRead)
|
||||
async def update_batch_endpoint(batch_id: str, update: BatchUpdate):
|
||||
if batch_id in active_batches:
|
||||
batch = active_batches[batch_id]
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
batch[key] = value
|
||||
batch["updated_at"] = datetime.now()
|
||||
|
||||
# Si el lote se completó, sacarlo del cache activo
|
||||
if batch.get("status") in (BatchStatus.COMPLETED, BatchStatus.FAILED):
|
||||
active_batches.pop(batch_id, None)
|
||||
|
||||
update_batch(batch_id, update_data)
|
||||
await broadcast({"event": "batch_updated", "data": batch})
|
||||
return batch
|
||||
|
||||
# Lote no está en memoria (completado), actualizar solo en DB
|
||||
batch = get_batch(batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="Lote no encontrado")
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
update_batch(batch_id, update_data)
|
||||
batch.update(update_data)
|
||||
return batch
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
message: str
|
||||
level: str = "info"
|
||||
radicado: Optional[str] = None
|
||||
|
||||
class LogRead(BaseModel):
|
||||
id: int
|
||||
batch_id: str
|
||||
timestamp: str
|
||||
level: str
|
||||
radicado: Optional[str]
|
||||
message: str
|
||||
|
||||
@app.post("/batches/{batch_id}/logs", status_code=201)
|
||||
async def add_log(batch_id: str, entry: LogEntry):
|
||||
"""Añade una entrada de log para un lote."""
|
||||
insert_log(batch_id, entry.message, entry.level, entry.radicado)
|
||||
# Broadcast log también por WebSocket para actualizaciones en vivo
|
||||
await broadcast({"event": "log_added", "batch_id": batch_id, "data": {
|
||||
"level": entry.level, "radicado": entry.radicado, "message": entry.message
|
||||
}})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/batches/{batch_id}/logs", response_model=List[LogRead])
|
||||
async def list_logs(batch_id: str, limit: int = 200):
|
||||
"""Retorna el historial de logs de un lote."""
|
||||
return get_logs(batch_id, limit)
|
||||
|
||||
|
||||
@app.get("/download/{batch_id}")
|
||||
async def download_csv(batch_id: str):
|
||||
"""Sirve el archivo CSV generado por el lote de extracción."""
|
||||
batch = get_batch(batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="Lote no encontrado")
|
||||
|
||||
csv_path = batch.get("csv_output_path")
|
||||
if not csv_path:
|
||||
raise HTTPException(status_code=404, detail="Este lote no tiene un archivo CSV asociado")
|
||||
if not os.path.isfile(csv_path):
|
||||
raise HTTPException(status_code=404, detail=f"Archivo CSV no encontrado en el servidor: {csv_path}")
|
||||
|
||||
filename = os.path.basename(csv_path)
|
||||
return FileResponse(
|
||||
path=csv_path,
|
||||
media_type="text/csv",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/ws/tracking")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
active_connections.append(websocket)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
if websocket in active_connections:
|
||||
active_connections.remove(websocket)
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BatchStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class BatchBase(BaseModel):
|
||||
root_path: str
|
||||
total_radicados: int
|
||||
csv_output_path: Optional[str] = None
|
||||
hostname: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
config: Optional[Dict] = None
|
||||
|
||||
|
||||
class BatchCreate(BatchBase):
|
||||
pass
|
||||
|
||||
|
||||
class BatchUpdate(BaseModel):
|
||||
status: Optional[BatchStatus] = None
|
||||
current_radicado_index: Optional[int] = None
|
||||
success_count: Optional[int] = None
|
||||
error_count: Optional[int] = None
|
||||
total_tokens: Optional[int] = None
|
||||
total_cost_usd: Optional[float] = None
|
||||
last_error: Optional[str] = None
|
||||
current_phase: Optional[str] = None
|
||||
current_radicado: Optional[str] = None
|
||||
|
||||
|
||||
class BatchRead(BatchBase):
|
||||
id: str
|
||||
status: BatchStatus
|
||||
current_radicado_index: int = 0
|
||||
success_count: int = 0
|
||||
error_count: int = 0
|
||||
total_tokens: int = 0
|
||||
total_cost_usd: float = 0.0
|
||||
last_error: Optional[str] = None
|
||||
current_phase: Optional[str] = None
|
||||
current_radicado: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY api /app/api
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,29 @@
|
||||
# IPSExtractor API Gateway
|
||||
|
||||
Gateway HTTP opcional. Esta carpeta puede vivir como repo propio: solo depende
|
||||
de HTTP para hablar con `extraction` y `cleaning`; no importa codigo interno de
|
||||
esos servicios.
|
||||
|
||||
## Estructura
|
||||
|
||||
```text
|
||||
api/ Endpoints FastAPI y proxy HTTP
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
```
|
||||
|
||||
## Desarrollo local
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Endpoints principales
|
||||
|
||||
- `GET /health`
|
||||
- `GET /models`
|
||||
- `GET /config`
|
||||
- `ANY /extraction/{path}`
|
||||
- `ANY /cleaning/{path}`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""API central opcional para enrutar a los microservicios."""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Gateway HTTP sin compartir codigo interno de los microservicios."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request, Response
|
||||
|
||||
|
||||
EXTRACTION_URL = os.getenv("EXTRACTION_URL", "http://extraction:8001").rstrip("/")
|
||||
CLEANING_URL = os.getenv("CLEANING_URL", "http://cleaning:8002").rstrip("/")
|
||||
|
||||
app = FastAPI(title="IPSExtractor API Gateway", version="1.0.0")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
extraction = await _safe_health(client, EXTRACTION_URL)
|
||||
cleaning = await _safe_health(client, CLEANING_URL)
|
||||
return {
|
||||
"status": "ok" if extraction["ok"] and cleaning["ok"] else "degraded",
|
||||
"services": {
|
||||
"extraction": extraction,
|
||||
"cleaning": cleaning,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/models")
|
||||
async def models() -> Response:
|
||||
return await _proxy("GET", f"{EXTRACTION_URL}/models")
|
||||
|
||||
|
||||
@app.get("/config")
|
||||
async def config() -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
extraction = await _safe_json(client, f"{EXTRACTION_URL}/config")
|
||||
cleaning_rules = await _safe_json(client, f"{CLEANING_URL}/rules")
|
||||
return {
|
||||
"extraction": extraction,
|
||||
"cleaning": {"rules": cleaning_rules},
|
||||
}
|
||||
|
||||
|
||||
@app.api_route("/extraction/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
||||
async def extraction_proxy(path: str, request: Request) -> Response:
|
||||
return await _proxy_request(request, f"{EXTRACTION_URL}/{path}")
|
||||
|
||||
|
||||
@app.api_route("/cleaning/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
||||
async def cleaning_proxy(path: str, request: Request) -> Response:
|
||||
return await _proxy_request(request, f"{CLEANING_URL}/{path}")
|
||||
|
||||
|
||||
async def _safe_health(client: httpx.AsyncClient, base_url: str) -> dict[str, Any]:
|
||||
try:
|
||||
response = await client.get(f"{base_url}/health")
|
||||
return {"ok": response.is_success, "status_code": response.status_code}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
async def _safe_json(client: httpx.AsyncClient, url: str) -> Any:
|
||||
try:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
async def _proxy(method: str, url: str) -> Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
upstream = await client.request(method, url)
|
||||
return _to_response(upstream)
|
||||
|
||||
|
||||
async def _proxy_request(request: Request, url: str) -> Response:
|
||||
body = await request.body()
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in {"host", "content-length"}
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
params=request.query_params,
|
||||
content=body,
|
||||
headers=headers,
|
||||
)
|
||||
return _to_response(upstream)
|
||||
|
||||
|
||||
def _to_response(upstream: httpx.Response) -> Response:
|
||||
excluded = {"content-encoding", "transfer-encoding", "connection"}
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in upstream.headers.items()
|
||||
if key.lower() not in excluded
|
||||
}
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
headers=headers,
|
||||
media_type=upstream.headers.get("content-type"),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
httpx==0.28.1
|
||||
uvicorn[standard]==0.32.1
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY domain /app/domain
|
||||
COPY infrastructure /app/infrastructure
|
||||
COPY api /app/api
|
||||
|
||||
EXPOSE 8002
|
||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# IPSExtractor Cleaning Service
|
||||
|
||||
Microservicio independiente de limpieza. Esta carpeta puede vivir como repo
|
||||
propio: contiene su API REST, reglas de limpieza, infraestructura, dependencias
|
||||
y Dockerfile sin importar codigo de otros servicios.
|
||||
|
||||
## Estructura
|
||||
|
||||
```text
|
||||
api/ Endpoints FastAPI
|
||||
domain/ Reglas y analisis de limpieza
|
||||
infrastructure/ Persistencia SQLAlchemy del servicio
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
```
|
||||
|
||||
## Desarrollo local
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8002
|
||||
```
|
||||
|
||||
## Endpoints principales
|
||||
|
||||
- `GET /health`
|
||||
- `GET /rules`
|
||||
- `GET /batches/{batch_id}/analysis`
|
||||
- `POST /jobs`
|
||||
- `GET /jobs/{job_id}`
|
||||
- `GET /jobs/{job_id}/rows`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Servicio REST de limpieza."""
|
||||
@@ -0,0 +1,200 @@
|
||||
"""API REST del microservicio de limpieza."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from domain.csv_cleaning import CLEANING_RULES, analyze_dataframe, apply_rules
|
||||
from infrastructure.database import (
|
||||
CleanedRow,
|
||||
CleaningJob,
|
||||
ExtractionBatch,
|
||||
ExtractionRow,
|
||||
init_db,
|
||||
session_scope,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="IPSExtractor Cleaning Service",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
class CleaningRequest(BaseModel):
|
||||
extraction_batch_id: str
|
||||
rules_per_column: dict[str, list[str]] = Field(default_factory=dict)
|
||||
cop_mod_fecha_column: dict[str, str] = Field(default_factory=dict)
|
||||
cop_mod_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def dataframe_to_records(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||
return json.loads(df.to_json(orient="records", date_format="iso"))
|
||||
|
||||
|
||||
def serialize_job(job: CleaningJob) -> dict[str, Any]:
|
||||
return {
|
||||
"id": job.id,
|
||||
"extraction_batch_id": job.extraction_batch_id,
|
||||
"status": job.status,
|
||||
"request": job.request,
|
||||
"log": job.log,
|
||||
"error": job.error,
|
||||
"created_at": job.created_at.isoformat(),
|
||||
"updated_at": job.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/rules")
|
||||
def list_rules() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": rule["id"],
|
||||
"name": rule["name"],
|
||||
"description": rule.get("description", ""),
|
||||
}
|
||||
for rule in CLEANING_RULES
|
||||
]
|
||||
|
||||
|
||||
@app.get("/batches/{batch_id}/analysis")
|
||||
def analyze_batch(batch_id: str) -> dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
batch = session.get(ExtractionBatch, batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(status_code=404, detail="Lote de extraccion no encontrado")
|
||||
rows = session.execute(
|
||||
select(ExtractionRow.row_json)
|
||||
.where(ExtractionRow.batch_id == batch_id)
|
||||
.order_by(ExtractionRow.row_index, ExtractionRow.id)
|
||||
).all()
|
||||
records = [row[0] for row in rows]
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"columns": [summary.__dict__ for summary in analyze_dataframe(df)],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/jobs")
|
||||
def run_cleaning(request: CleaningRequest) -> dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
batch = session.get(ExtractionBatch, request.extraction_batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(status_code=404, detail="Lote de extraccion no encontrado")
|
||||
|
||||
extracted_rows = session.execute(
|
||||
select(ExtractionRow.row_json)
|
||||
.where(ExtractionRow.batch_id == request.extraction_batch_id)
|
||||
.order_by(ExtractionRow.row_index, ExtractionRow.id)
|
||||
).all()
|
||||
records = [row[0] for row in extracted_rows]
|
||||
if not records:
|
||||
raise HTTPException(status_code=400, detail="El lote no tiene filas extraidas")
|
||||
|
||||
job = CleaningJob(
|
||||
extraction_batch_id=request.extraction_batch_id,
|
||||
status="running",
|
||||
request=request.model_dump(),
|
||||
updated_at=utcnow(),
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
job_id = job.id
|
||||
|
||||
try:
|
||||
df = pd.DataFrame(records)
|
||||
result_df, log = apply_rules(
|
||||
df,
|
||||
request.rules_per_column,
|
||||
cop_mod_fecha_column=request.cop_mod_fecha_column,
|
||||
cop_mod_config=request.cop_mod_config,
|
||||
)
|
||||
output_rows = dataframe_to_records(result_df)
|
||||
|
||||
with session_scope() as session:
|
||||
session.execute(delete(CleanedRow).where(CleanedRow.cleaning_job_id == job_id))
|
||||
for idx, row in enumerate(output_rows, start=1):
|
||||
session.add(
|
||||
CleanedRow(
|
||||
cleaning_job_id=job_id,
|
||||
extraction_batch_id=request.extraction_batch_id,
|
||||
row_index=idx,
|
||||
row_json=row,
|
||||
)
|
||||
)
|
||||
job = session.get(CleaningJob, job_id)
|
||||
if job is None:
|
||||
raise RuntimeError("No se encontro el job de limpieza recien creado.")
|
||||
job.status = "completed"
|
||||
job.log = log
|
||||
job.updated_at = utcnow()
|
||||
session.flush()
|
||||
return serialize_job(job)
|
||||
except Exception as exc:
|
||||
with session_scope() as session:
|
||||
job = session.get(CleaningJob, job_id)
|
||||
if job is not None:
|
||||
job.status = "failed"
|
||||
job.error = str(exc)
|
||||
job.updated_at = utcnow()
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/jobs")
|
||||
def list_jobs() -> list[dict[str, Any]]:
|
||||
with session_scope() as session:
|
||||
jobs = session.scalars(
|
||||
select(CleaningJob).order_by(CleaningJob.created_at.desc())
|
||||
).all()
|
||||
return [serialize_job(job) for job in jobs]
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}")
|
||||
def get_job(job_id: str) -> dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
job = session.get(CleaningJob, job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Job de limpieza no encontrado")
|
||||
return serialize_job(job)
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/rows")
|
||||
def get_cleaned_rows(job_id: str, limit: int = 500, offset: int = 0) -> dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
job = session.get(CleaningJob, job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Job de limpieza no encontrado")
|
||||
rows = session.scalars(
|
||||
select(CleanedRow)
|
||||
.where(CleanedRow.cleaning_job_id == job_id)
|
||||
.order_by(CleanedRow.row_index, CleanedRow.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"rows": [row.row_json for row in rows],
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Persistencia del servicio sobre la base de datos de integracion."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
create_engine,
|
||||
select,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
||||
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor",
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class ExtractionBatch(Base):
|
||||
__tablename__ = "extraction_batches"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
root_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class ExtractionRow(Base):
|
||||
__tablename__ = "extraction_rows"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
|
||||
radicado: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
source_pdf: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
row_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class CleaningJob(Base):
|
||||
__tablename__ = "cleaning_jobs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
extraction_batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
log: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class CleanedRow(Base):
|
||||
__tablename__ = "cleaned_rows"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
cleaning_job_id: Mapped[str] = mapped_column(ForeignKey("cleaning_jobs.id"), index=True)
|
||||
extraction_batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
|
||||
row_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
if DATABASE_URL.startswith("postgresql"):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("SELECT pg_advisory_lock(74512001)"))
|
||||
try:
|
||||
Base.metadata.create_all(bind=connection)
|
||||
finally:
|
||||
connection.execute(text("SELECT pg_advisory_unlock(74512001)"))
|
||||
return
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_extraction_rows(session: Session, batch_id: str) -> list[ExtractionRow]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(ExtractionRow)
|
||||
.where(ExtractionRow.batch_id == batch_id)
|
||||
.order_by(ExtractionRow.row_index, ExtractionRow.id)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
numpy==2.2.6
|
||||
pandas==2.2.3
|
||||
psycopg[binary]==3.2.3
|
||||
pydantic==2.11.9
|
||||
SQLAlchemy==2.0.36
|
||||
uvicorn[standard]==0.32.1
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends poppler-utils libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY domain /app/domain
|
||||
COPY core /app/core
|
||||
COPY infrastructure /app/infrastructure
|
||||
COPY api /app/api
|
||||
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,35 @@
|
||||
# IPSExtractor Extraction Service
|
||||
|
||||
Microservicio independiente de extraccion. Esta carpeta puede vivir como repo
|
||||
propio: contiene su API REST, dominio, infraestructura, dependencias y
|
||||
Dockerfile sin importar codigo de otros servicios.
|
||||
|
||||
## Estructura
|
||||
|
||||
```text
|
||||
api/ Endpoints FastAPI
|
||||
domain/ Logica de extraccion, prompts, estimacion y normalizacion
|
||||
core/ Utilidades propias de PDF, texto y CSV
|
||||
infrastructure/ Persistencia SQLAlchemy del servicio
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
```
|
||||
|
||||
## Desarrollo local
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
## Endpoints principales
|
||||
|
||||
- `GET /health`
|
||||
- `GET /config`
|
||||
- `GET /models`
|
||||
- `POST /schemas`
|
||||
- `POST /estimate`
|
||||
- `POST /batches`
|
||||
- `GET /batches/{batch_id}`
|
||||
- `GET /batches/{batch_id}/rows`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Servicio REST de extraccion."""
|
||||
@@ -0,0 +1,327 @@
|
||||
"""API REST del microservicio de extraccion."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from domain.batch_processor import BatchProcessor
|
||||
from domain.config import extractor_llm_config, normalizer_llm_config, settings
|
||||
from domain.config import get_allowed_document_types
|
||||
from domain.estimator import Estimator
|
||||
from domain.service import InvoiceProcessingService
|
||||
from domain.strategies import OpenAICompatibleExtractor
|
||||
from infrastructure.database import (
|
||||
ExtractionBatch,
|
||||
ExtractionRow,
|
||||
init_db,
|
||||
session_scope,
|
||||
utcnow,
|
||||
)
|
||||
from infrastructure.serialization import flatten_extraction_result
|
||||
|
||||
|
||||
def build_processing_service() -> InvoiceProcessingService:
|
||||
extractor_config = extractor_llm_config()
|
||||
extractor = OpenAICompatibleExtractor(
|
||||
api_key=str(extractor_config["api_key"]),
|
||||
base_url=str(extractor_config["base_url"]),
|
||||
model=str(extractor_config["model"]),
|
||||
)
|
||||
return InvoiceProcessingService(
|
||||
extractor,
|
||||
batch_processor_factory=lambda service: BatchProcessor(service),
|
||||
estimator_factory=lambda service: Estimator(service),
|
||||
)
|
||||
|
||||
|
||||
processing_service = build_processing_service()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="IPSExtractor Extraction Service",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
class DocumentType(BaseModel):
|
||||
name: str
|
||||
keyword: str = ""
|
||||
|
||||
|
||||
class SchemaRequest(BaseModel):
|
||||
document_path: str | None = None
|
||||
root_path: str | None = None
|
||||
doc_types: list[DocumentType] | None = None
|
||||
instruction: str | None = None
|
||||
tiff_page_range: tuple[int, int] | None = None
|
||||
structure_mode: str = "radicados"
|
||||
|
||||
|
||||
class ExtractionRequest(BaseModel):
|
||||
root_path: str
|
||||
keywords: list[str] | None = None
|
||||
doc_types: list[DocumentType] | None = None
|
||||
fields: list[str] = Field(default_factory=list)
|
||||
alias_map: dict[str, str] = Field(default_factory=dict)
|
||||
instruction: str | None = None
|
||||
tiff_page_range: tuple[int, int] | None = None
|
||||
structure_mode: str = "radicados"
|
||||
retry_on_empty: bool = False
|
||||
radicado_filter: list[str] | None = None
|
||||
|
||||
|
||||
class EstimateRequest(BaseModel):
|
||||
root_path: str
|
||||
keywords: list[str] | None = None
|
||||
doc_types: list[DocumentType] | None = None
|
||||
schema_map: dict[str, str] | None = Field(default=None, alias="schema")
|
||||
selected_fields: list[str] | None = None
|
||||
schema_by_type: dict[str, dict[str, str]] | None = None
|
||||
selected_paths: list[str] | None = None
|
||||
structure_mode: str = "radicados"
|
||||
|
||||
|
||||
class ExtractionBatchRead(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
root_path: str | None
|
||||
request: dict[str, Any]
|
||||
error: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def serialize_batch(batch: ExtractionBatch) -> dict[str, Any]:
|
||||
return {
|
||||
"id": batch.id,
|
||||
"status": batch.status,
|
||||
"root_path": batch.root_path,
|
||||
"request": batch.request,
|
||||
"error": batch.error,
|
||||
"created_at": batch.created_at.isoformat(),
|
||||
"updated_at": batch.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/config")
|
||||
def get_config() -> dict[str, Any]:
|
||||
extractor_config = extractor_llm_config()
|
||||
normalizer_config = normalizer_llm_config()
|
||||
return {
|
||||
"extractor": {
|
||||
"provider": extractor_config["provider"],
|
||||
"base_url": extractor_config["base_url"],
|
||||
"model": extractor_config["model"],
|
||||
"timeout_s": extractor_config["timeout_s"],
|
||||
"api_key_configured": bool(str(extractor_config["api_key"]).strip()),
|
||||
},
|
||||
"normalizer": {
|
||||
"provider": normalizer_config["provider"],
|
||||
"base_url": normalizer_config["base_url"],
|
||||
"model": normalizer_config["model"],
|
||||
"timeout_s": normalizer_config["timeout_s"],
|
||||
"api_key_configured": bool(str(normalizer_config["api_key"]).strip()),
|
||||
},
|
||||
"processing": {
|
||||
"concurrency_limit": settings.processing.concurrency_limit,
|
||||
"allowed_document_types": list(get_allowed_document_types()),
|
||||
},
|
||||
"imaging": {
|
||||
"pdf_dpi": settings.imaging.pdf_dpi,
|
||||
"pdf_thread_count": settings.imaging.pdf_thread_count,
|
||||
"tiff_pages": settings.imaging.tiff_pages,
|
||||
"poppler_path": settings.imaging.poppler_path,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/models")
|
||||
def list_models() -> dict[str, Any]:
|
||||
extractor_config = extractor_llm_config()
|
||||
normalizer_config = normalizer_llm_config()
|
||||
try:
|
||||
models = processing_service.extractor.llm.list_models()
|
||||
return {
|
||||
"available": models,
|
||||
"configured_extractor_model": extractor_config["model"],
|
||||
"configured_normalizer_model": normalizer_config["model"],
|
||||
"source": extractor_config["base_url"],
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"available": [],
|
||||
"configured_extractor_model": extractor_config["model"],
|
||||
"configured_normalizer_model": normalizer_config["model"],
|
||||
"source": extractor_config["base_url"],
|
||||
"warning": f"No se pudieron consultar modelos del backend: {exc}",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/schemas")
|
||||
def build_schema(request: SchemaRequest) -> dict[str, Any]:
|
||||
"""Descubre campos disponibles para que el cliente API elija `fields`."""
|
||||
if request.document_path:
|
||||
schema, sample = processing_service.build_schema_from_first(
|
||||
request.document_path,
|
||||
instruction=request.instruction,
|
||||
tiff_page_range=request.tiff_page_range,
|
||||
)
|
||||
return {"schema": schema, "sample": sample}
|
||||
|
||||
if request.root_path and request.doc_types:
|
||||
schema_by_type, sample_by_type, first_radicado = (
|
||||
processing_service.build_schema_from_first_for_types(
|
||||
request.root_path,
|
||||
[dt.model_dump() for dt in request.doc_types],
|
||||
instruction=request.instruction,
|
||||
tiff_page_range=request.tiff_page_range,
|
||||
structure_mode=request.structure_mode,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schema_by_type": schema_by_type,
|
||||
"sample_by_type": sample_by_type,
|
||||
"first_radicado": first_radicado,
|
||||
}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Envia document_path o root_path con doc_types para descubrir campos.",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/estimate")
|
||||
def estimate(request: EstimateRequest) -> dict[str, Any]:
|
||||
return processing_service.estimate_tokens_and_cost(
|
||||
request.root_path,
|
||||
request.keywords,
|
||||
request.schema_map,
|
||||
request.selected_fields,
|
||||
doc_types=[dt.model_dump() for dt in request.doc_types or []] or None,
|
||||
schema_by_type=request.schema_by_type,
|
||||
selected_paths=request.selected_paths,
|
||||
structure_mode=request.structure_mode,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/batches", response_model=ExtractionBatchRead)
|
||||
def run_extraction(request: ExtractionRequest) -> dict[str, Any]:
|
||||
if not request.fields:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="El API no tiene selector visual: envia fields. Usa POST /schemas para descubrirlos.",
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
batch = ExtractionBatch(
|
||||
status="running",
|
||||
root_path=request.root_path,
|
||||
request=request.model_dump(),
|
||||
updated_at=utcnow(),
|
||||
)
|
||||
session.add(batch)
|
||||
session.flush()
|
||||
batch_id = batch.id
|
||||
|
||||
try:
|
||||
results = processing_service.process_root_with_field_selection(
|
||||
request.root_path,
|
||||
request.keywords,
|
||||
doc_types=[dt.model_dump() for dt in request.doc_types or []] or None,
|
||||
fields=request.fields,
|
||||
csv_output_path=None,
|
||||
instruction_override=request.instruction,
|
||||
alias_map=request.alias_map,
|
||||
tiff_page_range=request.tiff_page_range,
|
||||
structure_mode=request.structure_mode,
|
||||
retry_on_empty=request.retry_on_empty,
|
||||
radicado_filter=request.radicado_filter,
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
row_index = 0
|
||||
for item in results:
|
||||
raw = item if isinstance(item, dict) else {"data": item}
|
||||
rows = flatten_extraction_result(raw, request.fields, request.alias_map)
|
||||
for row in rows:
|
||||
row_index += 1
|
||||
session.add(
|
||||
ExtractionRow(
|
||||
batch_id=batch_id,
|
||||
radicado=str(raw.get("radicado") or row.get("radicado") or ""),
|
||||
source_pdf=raw.get("_source_pdf"),
|
||||
row_index=row_index,
|
||||
row_json=row,
|
||||
raw_json=raw,
|
||||
)
|
||||
)
|
||||
batch = session.get(ExtractionBatch, batch_id)
|
||||
if batch is None:
|
||||
raise RuntimeError("No se encontro el lote recien creado.")
|
||||
batch.status = "completed"
|
||||
batch.updated_at = utcnow()
|
||||
session.flush()
|
||||
return serialize_batch(batch)
|
||||
except Exception as exc:
|
||||
with session_scope() as session:
|
||||
batch = session.get(ExtractionBatch, batch_id)
|
||||
if batch is not None:
|
||||
batch.status = "failed"
|
||||
batch.error = str(exc)
|
||||
batch.updated_at = utcnow()
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/batches")
|
||||
def list_batches() -> list[dict[str, Any]]:
|
||||
with session_scope() as session:
|
||||
batches = session.scalars(
|
||||
select(ExtractionBatch).order_by(ExtractionBatch.created_at.desc())
|
||||
).all()
|
||||
return [serialize_batch(batch) for batch in batches]
|
||||
|
||||
|
||||
@app.get("/batches/{batch_id}")
|
||||
def get_batch(batch_id: str) -> dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
batch = session.get(ExtractionBatch, batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(status_code=404, detail="Lote no encontrado")
|
||||
return serialize_batch(batch)
|
||||
|
||||
|
||||
@app.get("/batches/{batch_id}/rows")
|
||||
def get_rows(batch_id: str, limit: int = 500, offset: int = 0) -> dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
batch = session.get(ExtractionBatch, batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(status_code=404, detail="Lote no encontrado")
|
||||
rows = session.scalars(
|
||||
select(ExtractionRow)
|
||||
.where(ExtractionRow.batch_id == batch_id)
|
||||
.order_by(ExtractionRow.row_index, ExtractionRow.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"rows": [row.row_json for row in rows],
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Paquete `application`: lógica de alto nivel de extracción y orquestación.
|
||||
"""Paquete `domain`: lógica de alto nivel de extracción y orquestación.
|
||||
|
||||
Incluye:
|
||||
- `service`: fachada de procesamiento de facturas.
|
||||
- `flows`: flujos y utilidades para encabezados/CSV y ejecución paralela.
|
||||
- `batch_processor`: procesamiento por lotes y orquestación de radicados.
|
||||
- `normalizer`: normalización LLM a un esquema canónico.
|
||||
- `strategies`: estrategias de extracción (cliente OpenAI/Ollama).
|
||||
- `strategies`: estrategias de extracción con backends compatibles con Chat Completions.
|
||||
- `profiles`: perfiles por tipo de documento e instrucciones por defecto.
|
||||
- `prompts`: construcción de prompts de extracción/normalización.
|
||||
- `config`: configuración centralizada (Pydantic BaseSettings).
|
||||
@@ -9,17 +9,15 @@ import openai
|
||||
from typing import List, Dict, Any, Optional, Tuple, Callable, Awaitable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from application.service import InvoiceProcessingService
|
||||
from domain.service import InvoiceProcessingService
|
||||
|
||||
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, accumulate_usage
|
||||
from application.file_discovery import get_sorted_radicados, get_files_as_radicados
|
||||
from application.csv_manager import CSVManager
|
||||
from application.config import settings
|
||||
from application.app_prefs import get_allowed_document_types
|
||||
from application.tracking_manager import TrackingManager
|
||||
from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.normalizer import normalize_to_schema
|
||||
from domain.costs import compute_cost_from_usage, accumulate_usage
|
||||
from domain.file_discovery import get_sorted_radicados, get_files_as_radicados
|
||||
from domain.csv_manager import CSVManager
|
||||
from domain.config import settings, get_allowed_document_types
|
||||
from core.utils import collect_radicado_docs, find_first_matching_file, collect_documents_flat
|
||||
from core.csv_utils import merge_verbatim_long_strings, rows_from_data
|
||||
|
||||
@@ -36,6 +34,27 @@ DOCUMENT_TYPE_HINT_ALIASES = {
|
||||
"PASS",
|
||||
}
|
||||
|
||||
class BatchEventSink:
|
||||
"""Recibe eventos del lote sin depender de un servidor externo."""
|
||||
|
||||
def start_batch(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def update_progress(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def log(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def report_phase(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def report_error(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def complete_batch(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
class BatchProcessor:
|
||||
def __init__(self, service: 'InvoiceProcessingService'):
|
||||
"""
|
||||
@@ -44,11 +63,11 @@ class BatchProcessor:
|
||||
"""
|
||||
self.service = service
|
||||
self.csv_manager = CSVManager()
|
||||
self.tracking_manager = TrackingManager()
|
||||
self.event_sink = BatchEventSink()
|
||||
self._pause_event = asyncio.Event()
|
||||
self._pause_event.set() # No pausado inicialmente
|
||||
self._stop_requested = False
|
||||
# Contadores acumulados para tracking web
|
||||
# Contadores acumulados para progreso de lote
|
||||
self._track_tokens: int = 0
|
||||
self._track_cost_usd: float = 0.0
|
||||
self._track_success: int = 0
|
||||
@@ -77,7 +96,7 @@ class BatchProcessor:
|
||||
|
||||
Delega en métodos específicos para multi-documento o single-document.
|
||||
"""
|
||||
# Resetear estado de pausa/parada y contadores de tracking
|
||||
# Resetear estado de pausa/parada y contadores de progreso
|
||||
self._pause_event.set()
|
||||
self._stop_requested = False
|
||||
self._track_tokens = 0
|
||||
@@ -85,7 +104,7 @@ class BatchProcessor:
|
||||
self._track_success = 0
|
||||
self._track_errors = 0
|
||||
|
||||
# Notificar inicio a tracking si es procesamiento masivo
|
||||
# Notificar inicio si es procesamiento masivo
|
||||
if fields:
|
||||
# Intentar estimar total de radicados
|
||||
try:
|
||||
@@ -98,7 +117,7 @@ class BatchProcessor:
|
||||
filter_set = {r.strip() for r in radicado_filter if r.strip()}
|
||||
temp_rads = [r for r in temp_rads if r in filter_set]
|
||||
|
||||
self.tracking_manager.start_batch(
|
||||
self.event_sink.start_batch(
|
||||
root_path,
|
||||
len(temp_rads),
|
||||
config={
|
||||
@@ -108,7 +127,7 @@ class BatchProcessor:
|
||||
csv_output_path=csv_output_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error al iniciar tracking: {e}")
|
||||
logger.error(f"Error al iniciar progreso del lote: {e}")
|
||||
|
||||
# 1. Preparación de Schemas (Fase 1 Multi-doc)
|
||||
if doc_types and not fields:
|
||||
@@ -286,8 +305,8 @@ class BatchProcessor:
|
||||
prompt_t = PromptBuilder.build(selected_paths_for_type, {}, profile_name=prof.get('name', 'invoice'))
|
||||
|
||||
# Llamada ASYNC - Fase: Extracción
|
||||
self.tracking_manager.report_phase("extract", rad_id)
|
||||
self.tracking_manager.log(f"Iniciando extracción IA", level="info", radicado=rad_id)
|
||||
self.event_sink.report_phase("extract", rad_id)
|
||||
self.event_sink.log(f"Iniciando extracción IA", level="info", radicado=rad_id)
|
||||
data, usage_ext = await self.service.extract_full_from_pdf_async(match, instruction=prompt_t, tiff_page_range=tiff_page_range)
|
||||
self._accumulate_usage(agg_extract_usage, usage_ext)
|
||||
|
||||
@@ -299,8 +318,8 @@ class BatchProcessor:
|
||||
data = {"radicado": rad_id, "data": data}
|
||||
|
||||
# Normalización en thread aparte - Fase: Normalización
|
||||
self.tracking_manager.report_phase("normalize", rad_id)
|
||||
self.tracking_manager.log(f"Extracción OK — normalizando campos", level="info", radicado=rad_id)
|
||||
self.event_sink.report_phase("normalize", rad_id)
|
||||
self.event_sink.log(f"Extracción OK — normalizando campos", level="info", radicado=rad_id)
|
||||
norm = await asyncio.to_thread(
|
||||
normalize_to_schema,
|
||||
data,
|
||||
@@ -478,8 +497,8 @@ class BatchProcessor:
|
||||
logger.info(f"Procesando radicado {entry['radicado']} [{idx}/{total}]", extra={"radicado": entry['radicado'], "pdf": entry['path']})
|
||||
|
||||
# Async Extract - Fase: Extracción
|
||||
self.tracking_manager.report_phase("extract", entry['radicado'])
|
||||
self.tracking_manager.log(f"Iniciando extracción IA", level="info", radicado=entry['radicado'])
|
||||
self.event_sink.report_phase("extract", entry['radicado'])
|
||||
self.event_sink.log(f"Iniciando extracción IA", level="info", radicado=entry['radicado'])
|
||||
data, usage_ext = await self.service.extract_full_from_pdf_async(entry["path"], instruction=prompt, tiff_page_range=tiff_page_range)
|
||||
self._report_usage(on_usage, entry['radicado'], "extract", usage_ext)
|
||||
|
||||
@@ -491,8 +510,8 @@ class BatchProcessor:
|
||||
data["_source_pdf"] = entry["path"]
|
||||
|
||||
# Normalizar (Thread) - Fase: Normalización
|
||||
self.tracking_manager.report_phase("normalize", entry['radicado'])
|
||||
self.tracking_manager.log(f"Extracción OK — normalizando campos", level="info", radicado=entry['radicado'])
|
||||
self.event_sink.report_phase("normalize", entry['radicado'])
|
||||
self.event_sink.log(f"Extracción OK — normalizando campos", level="info", radicado=entry['radicado'])
|
||||
norm = await asyncio.to_thread(
|
||||
normalize_to_schema,
|
||||
data, paths=selected_fields, profile_name=prof.get('name', 'invoice')
|
||||
@@ -714,25 +733,25 @@ class BatchProcessor:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Notificar a tracking web con contadores reales acumulados
|
||||
# Notificar progreso con contadores reales acumulados
|
||||
try:
|
||||
self._track_success = idx - self._track_errors
|
||||
# Log de radicado completado exitosamente
|
||||
if rad and self._track_errors < idx:
|
||||
self.tracking_manager.log(
|
||||
self.event_sink.log(
|
||||
f"Radicado completado ({self._track_success}/{total} exitosos)",
|
||||
level="success",
|
||||
radicado=str(rad)
|
||||
)
|
||||
if idx >= total:
|
||||
self.tracking_manager.complete_batch(
|
||||
self.event_sink.complete_batch(
|
||||
success_count=self._track_success,
|
||||
error_count=self._track_errors,
|
||||
total_tokens=self._track_tokens,
|
||||
total_cost_usd=self._track_cost_usd,
|
||||
)
|
||||
else:
|
||||
self.tracking_manager.update_progress(
|
||||
self.event_sink.update_progress(
|
||||
current_index=idx,
|
||||
success_count=self._track_success,
|
||||
error_count=self._track_errors,
|
||||
@@ -741,7 +760,7 @@ class BatchProcessor:
|
||||
status="processing"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error al notificar progreso a tracking: {e}")
|
||||
logger.debug(f"Error al notificar progreso del lote: {e}")
|
||||
|
||||
def _accumulate_usage(self, agg, new_usage):
|
||||
accumulate_usage(agg, new_usage)
|
||||
@@ -754,11 +773,11 @@ class BatchProcessor:
|
||||
if phase == "extract":
|
||||
model = getattr(self.service.extractor, "model", None)
|
||||
elif phase == "normalize":
|
||||
model = "gpt-4.1-mini"
|
||||
model = settings.api.normalizer_model
|
||||
|
||||
cost = compute_cost_from_usage(usage, phase, model=model)
|
||||
|
||||
# Acumular tokens y costos para el tracking web
|
||||
# Acumular tokens y costos para el progreso del lote
|
||||
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||
self._track_tokens += prompt_tokens + completion_tokens
|
||||
@@ -872,15 +891,15 @@ class BatchProcessor:
|
||||
if pdf_path: error_obj["_source_pdf"] = pdf_path
|
||||
logger.exception(f"Error procesando radicado {rad}", extra={"radicado": rad})
|
||||
|
||||
# Acumular error en el contador de tracking
|
||||
# Acumular error en el contador de lote
|
||||
self._track_errors += 1
|
||||
error_message = str(e)
|
||||
try:
|
||||
self.tracking_manager.report_error(f"[{rad}] {error_message}")
|
||||
self.event_sink.report_error(f"[{rad}] {error_message}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.tracking_manager.log(
|
||||
self.event_sink.log(
|
||||
message=f"Error procesando radicado: {error_message}",
|
||||
level="error",
|
||||
radicado=str(rad)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Configuración centralizada de la aplicación.
|
||||
|
||||
Usa Pydantic BaseSettings para cargar valores por defecto y variables de entorno.
|
||||
Se lee automáticamente un archivo `.env` en la raíz del proyecto si existe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Cargar variables desde .env en el entorno (para que otros módulos que usan os.environ las vean)
|
||||
try:
|
||||
from dotenv import load_dotenv # type: ignore
|
||||
# En desarrollo, dar precedencia al archivo .env sobre variables previas del entorno
|
||||
load_dotenv(override=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import AliasChoices
|
||||
|
||||
|
||||
class ApiSettings(BaseSettings):
|
||||
"""Backends LLM compatibles con la API de OpenAI.
|
||||
|
||||
Extraccion y normalizacion tienen credenciales independientes para poder
|
||||
mezclar proveedores, por ejemplo Grok para vision y OpenAI para normalizar.
|
||||
"""
|
||||
extractor_provider: str = Field("custom", validation_alias=AliasChoices("EXTRACTOR_PROVIDER"))
|
||||
extractor_base_url: str = Field("", validation_alias=AliasChoices("EXTRACTOR_BASE_URL"))
|
||||
extractor_api_key: str = Field("", validation_alias=AliasChoices("EXTRACTOR_API_KEY"))
|
||||
extractor_model: str = Field("", validation_alias=AliasChoices("EXTRACTOR_MODEL"))
|
||||
|
||||
normalizer_provider: str = Field("custom", validation_alias=AliasChoices("NORMALIZER_PROVIDER"))
|
||||
normalizer_base_url: str = Field("", validation_alias=AliasChoices("NORMALIZER_BASE_URL"))
|
||||
normalizer_api_key: str = Field("", validation_alias=AliasChoices("NORMALIZER_API_KEY"))
|
||||
normalizer_model: str = Field("", validation_alias=AliasChoices("NORMALIZER_MODEL"))
|
||||
|
||||
request_timeout_s: int = Field(120, validation_alias=AliasChoices("EXTRACTOR_TIMEOUT_S", "LLM_TIMEOUT_S"))
|
||||
normalizer_timeout_s: int = Field(0, validation_alias=AliasChoices("NORMALIZER_TIMEOUT_S"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""Alias de compatibilidad para codigo anterior: backend de extraccion."""
|
||||
return self.extractor_base_url
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
"""Alias de compatibilidad para codigo anterior: API key de extraccion."""
|
||||
return self.extractor_api_key
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""Alias de compatibilidad para codigo anterior: modelo de extraccion."""
|
||||
return self.extractor_model
|
||||
|
||||
@property
|
||||
def effective_normalizer_base_url(self) -> str:
|
||||
return self.normalizer_base_url or self.extractor_base_url
|
||||
|
||||
@property
|
||||
def effective_normalizer_api_key(self) -> str:
|
||||
return self.normalizer_api_key or self.extractor_api_key
|
||||
|
||||
@property
|
||||
def effective_normalizer_timeout_s(self) -> int:
|
||||
return self.normalizer_timeout_s or self.request_timeout_s
|
||||
|
||||
|
||||
class ImagingSettings(BaseSettings):
|
||||
"""Parámetros de conversión de documentos a imágenes."""
|
||||
poppler_path: Optional[str] = Field(None, validation_alias=AliasChoices("POPPLER_PATH"))
|
||||
pdf_dpi: int = Field(600, validation_alias=AliasChoices("PDF_DPI"))
|
||||
pdf_thread_count: int = Field(10, validation_alias=AliasChoices("PDF_THREAD_COUNT"))
|
||||
tiff_pages: Optional[Tuple[int, int]] = Field((2, 3), validation_alias=AliasChoices("TIFF_PAGES"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
class RetrySettings(BaseSettings):
|
||||
"""Estrategia de reintentos para llamadas al backend."""
|
||||
max_attempts: int = Field(3, validation_alias=AliasChoices("RETRY_MAX_ATTEMPTS"))
|
||||
wait_min_s: float = Field(1.0, validation_alias=AliasChoices("RETRY_WAIT_MIN_S"))
|
||||
wait_max_s: float = Field(8.0, validation_alias=AliasChoices("RETRY_WAIT_MAX_S"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
class ProcessingSettings(BaseSettings):
|
||||
"""Configuración de procesamiento y concurrencia."""
|
||||
concurrency_limit: int = Field(100, validation_alias=AliasChoices("CONCURRENCY_LIMIT"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
"""Configuración raíz agrupada."""
|
||||
api: ApiSettings = ApiSettings()
|
||||
imaging: ImagingSettings = ImagingSettings()
|
||||
retry: RetrySettings = RetrySettings()
|
||||
processing: ProcessingSettings = ProcessingSettings()
|
||||
csv_delimiter: str = Field("|", validation_alias=AliasChoices("CSV_DELIMITER"))
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
|
||||
def extractor_llm_config() -> dict[str, str | int]:
|
||||
"""Configuracion efectiva para llamadas de extraccion."""
|
||||
api = settings.api
|
||||
return {
|
||||
"provider": api.extractor_provider or "custom",
|
||||
"api_key": api.extractor_api_key,
|
||||
"base_url": api.extractor_base_url,
|
||||
"model": api.extractor_model,
|
||||
"timeout_s": api.request_timeout_s,
|
||||
}
|
||||
|
||||
|
||||
def normalizer_llm_config() -> dict[str, str | int]:
|
||||
"""Configuracion efectiva para llamadas de normalizacion."""
|
||||
api = settings.api
|
||||
return {
|
||||
"provider": api.normalizer_provider or "custom",
|
||||
"api_key": api.effective_normalizer_api_key,
|
||||
"base_url": api.effective_normalizer_base_url,
|
||||
"model": api.normalizer_model,
|
||||
"timeout_s": api.effective_normalizer_timeout_s,
|
||||
}
|
||||
|
||||
|
||||
# Tipos documentales válidos por defecto para el campo especial `documento_forzate`.
|
||||
# NIT se excluye explícitamente porque no debe tratarse como tipo de documento personal.
|
||||
DEFAULT_ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = (
|
||||
"CC",
|
||||
"TI",
|
||||
"CE",
|
||||
"RC",
|
||||
"PA",
|
||||
"PPT",
|
||||
"PEP",
|
||||
"EPT",
|
||||
"NUIP",
|
||||
)
|
||||
|
||||
# Alias de compatibilidad.
|
||||
ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = DEFAULT_ALLOWED_DOCUMENT_TYPES
|
||||
|
||||
|
||||
def normalize_document_types(values: object) -> tuple[str, ...]:
|
||||
"""Normaliza tipos documentales configurables para API/servicios."""
|
||||
raw_values = values if isinstance(values, (list, tuple, set)) else []
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_values:
|
||||
text = str(item or "").strip().upper()
|
||||
if not text or text == "NIT" or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
normalized.append(text)
|
||||
return tuple(normalized or DEFAULT_ALLOWED_DOCUMENT_TYPES)
|
||||
|
||||
|
||||
def get_allowed_document_types() -> tuple[str, ...]:
|
||||
"""Devuelve tipos documentales válidos desde `ALLOWED_DOCUMENT_TYPES` o defaults."""
|
||||
raw = os.getenv("ALLOWED_DOCUMENT_TYPES", "")
|
||||
if not raw.strip():
|
||||
return DEFAULT_ALLOWED_DOCUMENT_TYPES
|
||||
return normalize_document_types([part.strip() for part in raw.split(",")])
|
||||
@@ -34,10 +34,16 @@ MODEL_PRICING = {
|
||||
},
|
||||
}
|
||||
|
||||
# Legacy mapping o fase por defecto (si no se especifica modelo)
|
||||
ZERO_PRICING = {
|
||||
"input": 0.0,
|
||||
"cached_input": 0.0,
|
||||
"output": 0.0,
|
||||
}
|
||||
|
||||
# Fallback por fase cuando no hay tarifa conocida para el modelo configurado.
|
||||
PRICING = {
|
||||
"extract": MODEL_PRICING["gpt-5.4"],
|
||||
"normalize": MODEL_PRICING["gpt-4.1-mini"],
|
||||
"extract": ZERO_PRICING,
|
||||
"normalize": ZERO_PRICING,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import os
|
||||
from typing import List, Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from application.service import InvoiceProcessingService
|
||||
from domain.service import InvoiceProcessingService
|
||||
|
||||
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, accumulate_usage
|
||||
from application.file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados
|
||||
from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.normalizer import normalize_to_schema
|
||||
from domain.costs import compute_cost_from_usage, sum_costs, accumulate_usage
|
||||
from domain.config import settings
|
||||
from domain.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
|
||||
|
||||
class Estimator:
|
||||
@@ -112,8 +113,7 @@ class Estimator:
|
||||
def _build_result(self, usage_ext, usage_norm, total_docs, first_radicado):
|
||||
model_ext = getattr(self.service.extractor, "model", None)
|
||||
cost_ext = compute_cost_from_usage(usage_ext, "extract", model=model_ext)
|
||||
# El normalizador está hardcodeado a gpt-4.1-mini en normalizer.py
|
||||
cost_norm = compute_cost_from_usage(usage_norm, "normalize", model="gpt-4.1-mini")
|
||||
cost_norm = compute_cost_from_usage(usage_norm, "normalize", model=settings.api.normalizer_model)
|
||||
per_doc_cost = sum_costs(cost_ext, cost_norm)
|
||||
estimated_total = {k: round(v * total_docs, 6) for k, v in per_doc_cost.items()}
|
||||
|
||||
@@ -7,15 +7,15 @@ import logging
|
||||
from openai import OpenAI, AsyncOpenAI
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
|
||||
from .config import settings
|
||||
from .config import extractor_llm_config, settings
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Cliente unificado para llamadas Chat Completions (SDK OpenAI).
|
||||
|
||||
- Centraliza api_key, base_url, modelo y timeout desde `settings`.
|
||||
- Centraliza api_key, base_url, modelo y timeout desde configuracion.
|
||||
- Expone helpers para respuestas de texto y JSON.
|
||||
- Registra métricas mínimas en `application.api_calls` (start/end/error + usage).
|
||||
- Registra métricas mínimas en `domain.api_calls` (start/end/error + usage).
|
||||
- Aplica reintentos ante timeouts.
|
||||
"""
|
||||
|
||||
@@ -26,13 +26,26 @@ class LLMClient:
|
||||
default_model: Optional[str] = None,
|
||||
request_timeout_s: Optional[float] = None,
|
||||
) -> None:
|
||||
self.api_key = api_key or settings.api.api_key
|
||||
self.base_url = base_url or settings.api.base_url
|
||||
self.default_model = default_model or settings.api.model
|
||||
self.request_timeout_s = request_timeout_s or settings.api.request_timeout_s
|
||||
self._client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
self._api_logger = logging.getLogger("application.api_calls")
|
||||
default_config = extractor_llm_config()
|
||||
self.api_key = api_key if api_key is not None else str(default_config["api_key"])
|
||||
self.base_url = base_url if base_url is not None else str(default_config["base_url"])
|
||||
self.default_model = default_model if default_model is not None else str(default_config["model"])
|
||||
self.request_timeout_s = request_timeout_s or float(default_config["timeout_s"])
|
||||
self._client = self._build_sync_client()
|
||||
self._async_client = self._build_async_client()
|
||||
self._api_logger = logging.getLogger("domain.api_calls")
|
||||
|
||||
def _build_sync_client(self) -> OpenAI:
|
||||
kwargs: Dict[str, Any] = {"api_key": self.api_key}
|
||||
if self.base_url:
|
||||
kwargs["base_url"] = self.base_url
|
||||
return OpenAI(**kwargs)
|
||||
|
||||
def _build_async_client(self) -> AsyncOpenAI:
|
||||
kwargs: Dict[str, Any] = {"api_key": self.api_key}
|
||||
if self.base_url:
|
||||
kwargs["base_url"] = self.base_url
|
||||
return AsyncOpenAI(**kwargs)
|
||||
|
||||
def update_config(
|
||||
self,
|
||||
@@ -50,8 +63,17 @@ class LLMClient:
|
||||
self.default_model = default_model
|
||||
if request_timeout_s is not None:
|
||||
self.request_timeout_s = request_timeout_s
|
||||
self._client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
self._client = self._build_sync_client()
|
||||
self._async_client = self._build_async_client()
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Lista modelos publicados por el backend compatible con OpenAI."""
|
||||
models = self._client.models.list()
|
||||
return sorted(
|
||||
str(getattr(model, "id", ""))
|
||||
for model in getattr(models, "data", [])
|
||||
if getattr(model, "id", None)
|
||||
)
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(settings.retry.max_attempts),
|
||||
@@ -147,7 +147,7 @@ def configure_logging(level: int = logging.INFO) -> None:
|
||||
root.addHandler(console)
|
||||
|
||||
# Logger dedicado para normalizador (LLM)
|
||||
llm_logger = logging.getLogger("application.normalizer")
|
||||
llm_logger = logging.getLogger("domain.normalizer")
|
||||
# evitar duplicados si ya está configurado
|
||||
has_file = any(isinstance(h, logging.handlers.RotatingFileHandler) for h in llm_logger.handlers)
|
||||
if not has_file:
|
||||
@@ -164,7 +164,7 @@ def configure_logging(level: int = logging.INFO) -> None:
|
||||
llm_logger.propagate = False # No propagar al root para no duplicar en consola/main log si no se desea
|
||||
|
||||
# Logger dedicado para llamadas a APIs (peticiones y tokens)
|
||||
api_calls_logger = logging.getLogger("application.api_calls")
|
||||
api_calls_logger = logging.getLogger("domain.api_calls")
|
||||
has_api_file = any(isinstance(h, logging.handlers.RotatingFileHandler) for h in api_calls_logger.handlers)
|
||||
if not has_api_file:
|
||||
api_handler = logging.handlers.RotatingFileHandler(
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Normalizador LLM: adapta un JSON fuente al esquema canónico de rutas.
|
||||
|
||||
Usa la API de OpenAI (Chat Completions) para transformar el JSON devuelto por
|
||||
el extractor a un objeto que respete exactamente las rutas/campos canónicos
|
||||
Usa un backend compatible con Chat Completions para transformar el JSON devuelto
|
||||
por el extractor a un objeto que respete exactamente las rutas/campos canónicos
|
||||
seleccionados por el usuario (incluyendo listas items/*).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -10,12 +10,22 @@ import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import settings
|
||||
from .config import normalizer_llm_config, settings
|
||||
from .prompts import PromptBuilder
|
||||
from .llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger('application.normalizer')
|
||||
_llm_client = LLMClient()
|
||||
logger = logging.getLogger('domain.normalizer')
|
||||
|
||||
|
||||
def _build_normalizer_client() -> tuple[LLMClient, dict[str, Any]]:
|
||||
config = normalizer_llm_config()
|
||||
client = LLMClient(
|
||||
api_key=str(config["api_key"]),
|
||||
base_url=str(config["base_url"]),
|
||||
default_model=str(config["model"]),
|
||||
request_timeout_s=float(config["timeout_s"]),
|
||||
)
|
||||
return client, config
|
||||
|
||||
|
||||
def _ensure_dict_at_path(root: Dict[str, Any], key: str) -> Dict[str, Any]:
|
||||
@@ -96,10 +106,10 @@ def normalize_to_schema(
|
||||
template = build_canonical_template(paths)
|
||||
# Construcción centralizada de mensajes
|
||||
messages = PromptBuilder.build_normalization_messages(profile_name, template, data)
|
||||
# Forzar modelo del normalizador a gpt-5-mini
|
||||
eff_model = "gpt-4.1-mini"
|
||||
eff_base_url = settings.api.base_url or "https://api.openai.com/v1"
|
||||
eff_timeout = getattr(settings.api, "request_timeout_s", 30)
|
||||
llm_client, normalizer_config = _build_normalizer_client()
|
||||
eff_model = str(normalizer_config["model"])
|
||||
eff_base_url = str(normalizer_config["base_url"])
|
||||
eff_timeout = float(normalizer_config["timeout_s"])
|
||||
# log inicio
|
||||
try:
|
||||
logger.info(
|
||||
@@ -118,7 +128,7 @@ def normalize_to_schema(
|
||||
|
||||
# Llamada centralizada vía LLMClient (JSON estricto)
|
||||
try:
|
||||
normalized_obj, usage = _llm_client.chat_json(
|
||||
normalized_obj, usage = llm_client.chat_json(
|
||||
messages=messages, model=eff_model,
|
||||
response_format="json_object",
|
||||
timeout_s=float(eff_timeout),
|
||||
@@ -178,13 +188,11 @@ def normalize_to_schema(
|
||||
"normalized": False,
|
||||
"error": str(ex),
|
||||
"duration_ms": dt_ms,
|
||||
"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")),
|
||||
"model": (eff_model if 'eff_model' in locals() else settings.api.normalizer_model),
|
||||
"base_url": (eff_base_url if 'eff_base_url' in locals() else settings.api.effective_normalizer_base_url),
|
||||
"radicado": (data.get("radicado") if isinstance(data, dict) else None),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from .profiles import get_profile
|
||||
from .app_prefs import get_allowed_document_types
|
||||
from .config import get_allowed_document_types
|
||||
|
||||
|
||||
def _build_instructions() -> List[str]:
|
||||
@@ -114,4 +114,3 @@ class PromptBuilder:
|
||||
extra_meta={"tipo_documento": doc_type_name},
|
||||
)
|
||||
return json.dumps(payload, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Estrategias de extracción de información desde imágenes de documentos.
|
||||
|
||||
Define la interfaz `InvoiceExtractorStrategy` y una implementación basada en
|
||||
un endpoint compatible con OpenAI (por ejemplo, Ollama) para modelos
|
||||
multimodales como `qwen2.5vl`.
|
||||
un endpoint compatible con la API de OpenAI para modelos multimodales.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from .config import settings
|
||||
from .config import extractor_llm_config
|
||||
from .llm_client import LLMClient
|
||||
import logging
|
||||
import time
|
||||
@@ -30,16 +29,23 @@ class InvoiceExtractorStrategy(ABC):
|
||||
_LOG_EXTRA_BASE = {"dpi": '-', "pdf": '-', "radicado": '-'}
|
||||
|
||||
|
||||
class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
"""Extractor contra un servidor Ollama/OpenAI-compatible.
|
||||
class OpenAICompatibleExtractor(InvoiceExtractorStrategy):
|
||||
"""Extractor contra un backend compatible con Chat Completions.
|
||||
|
||||
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:
|
||||
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)
|
||||
config = extractor_llm_config()
|
||||
self.api_key = api_key if api_key is not None else str(config["api_key"])
|
||||
self.base_url = base_url if base_url is not None else str(config["base_url"])
|
||||
self.model = model if model is not None else str(config["model"])
|
||||
self.request_timeout_s = float(config["timeout_s"])
|
||||
self.llm = llm_client or LLMClient(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
default_model=self.model,
|
||||
request_timeout_s=self.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]]:
|
||||
@@ -57,7 +63,7 @@ class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
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),
|
||||
"timeout_s": self.request_timeout_s, "images": len(images_b64),
|
||||
"prompt_len": prompt_len, "history_len": len(history) if history else 0},
|
||||
)
|
||||
self._logger.debug(
|
||||
@@ -69,7 +75,7 @@ class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
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)},
|
||||
"timeout_s": self.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
|
||||
)
|
||||
self._logger.debug(f"Backend OK (elapsed_ms={int(elapsed*1000)})", extra=_LOG_EXTRA_BASE)
|
||||
|
||||
@@ -77,7 +83,7 @@ class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
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)},
|
||||
"timeout_s": self.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
|
||||
)
|
||||
self._logger.debug(f"Backend error tras {int(elapsed*1000)} ms", extra=_LOG_EXTRA_BASE)
|
||||
|
||||
@@ -89,7 +95,7 @@ class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
try:
|
||||
text, usage_dict = self.llm.chat_text(
|
||||
messages=messages, model=self.model,
|
||||
timeout_s=settings.api.request_timeout_s, component="extractor",
|
||||
timeout_s=self.request_timeout_s, component="extractor",
|
||||
)
|
||||
self._log_call_ok(time.time() - start)
|
||||
return {"raw": text, "usage": usage_dict}
|
||||
@@ -105,7 +111,7 @@ class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
try:
|
||||
text, usage_dict = await self.llm.chat_text_async(
|
||||
messages=messages, model=self.model,
|
||||
timeout_s=settings.api.request_timeout_s, component="extractor",
|
||||
timeout_s=self.request_timeout_s, component="extractor",
|
||||
)
|
||||
self._log_call_ok(time.time() - start)
|
||||
return {"raw": text, "usage": usage_dict}
|
||||
@@ -122,4 +128,4 @@ class OllamaExtractor(InvoiceExtractorStrategy):
|
||||
if model is not None:
|
||||
self.model = model
|
||||
# actualizar cliente LLM con config actual
|
||||
self.llm.update_config(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=settings.api.request_timeout_s)
|
||||
self.llm.update_config(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=self.request_timeout_s)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Persistencia del servicio sobre la base de datos de integracion."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
create_engine,
|
||||
select,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
||||
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor",
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class ExtractionBatch(Base):
|
||||
__tablename__ = "extraction_batches"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
root_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class ExtractionRow(Base):
|
||||
__tablename__ = "extraction_rows"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
|
||||
radicado: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
source_pdf: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
row_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class CleaningJob(Base):
|
||||
__tablename__ = "cleaning_jobs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
extraction_batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
log: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class CleanedRow(Base):
|
||||
__tablename__ = "cleaned_rows"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
cleaning_job_id: Mapped[str] = mapped_column(ForeignKey("cleaning_jobs.id"), index=True)
|
||||
extraction_batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
|
||||
row_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
if DATABASE_URL.startswith("postgresql"):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("SELECT pg_advisory_lock(74512001)"))
|
||||
try:
|
||||
Base.metadata.create_all(bind=connection)
|
||||
finally:
|
||||
connection.execute(text("SELECT pg_advisory_unlock(74512001)"))
|
||||
return
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_extraction_rows(session: Session, batch_id: str) -> list[ExtractionRow]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(ExtractionRow)
|
||||
.where(ExtractionRow.batch_id == batch_id)
|
||||
.order_by(ExtractionRow.row_index, ExtractionRow.id)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Helpers para serializar resultados extraidos como filas de base de datos."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.csv_utils import rows_from_data
|
||||
|
||||
|
||||
def expand_alias_map(alias_map: dict[str, str]) -> dict[str, str]:
|
||||
expanded = dict(alias_map or {})
|
||||
for original, alias in list(expanded.items()):
|
||||
if "/*/" in original:
|
||||
tail = original.split("/*/", 1)[1]
|
||||
if tail:
|
||||
expanded.setdefault(tail, alias)
|
||||
return expanded
|
||||
|
||||
|
||||
def flatten_extraction_result(
|
||||
data: dict[str, Any],
|
||||
fields: list[str] | None,
|
||||
alias_map: dict[str, str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows, _header = rows_from_data(data, allowed_paths=fields)
|
||||
if not alias_map:
|
||||
return rows
|
||||
|
||||
aliases = expand_alias_map(alias_map)
|
||||
aliased: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
new_row: dict[str, Any] = {}
|
||||
for key, value in row.items():
|
||||
new_key = aliases.get(key, key)
|
||||
if new_key in new_row and new_row[new_key] not in (None, ""):
|
||||
if value not in (None, "") and str(value) != str(new_row[new_key]):
|
||||
new_row[new_key] = f"{new_row[new_key]}, {value}"
|
||||
else:
|
||||
new_row[new_key] = value
|
||||
aliased.append(new_row)
|
||||
return aliased
|
||||
@@ -0,0 +1,12 @@
|
||||
coloredlogs==15.0.1
|
||||
fastapi==0.115.6
|
||||
openai==2.1.0
|
||||
pdf2image==1.17.0
|
||||
pillow==11.3.0
|
||||
psycopg[binary]==3.2.3
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.11.0
|
||||
python-dotenv==1.1.1
|
||||
SQLAlchemy==2.0.36
|
||||
tenacity==9.1.2
|
||||
uvicorn[standard]==0.32.1
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Asegurar que el directorio raíz del proyecto esté en sys.path para imports como `from application...`
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
root_str = str(ROOT)
|
||||
if root_str not in sys.path:
|
||||
sys.path.insert(0, root_str)
|
||||
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from application.csv_cleaning import (
|
||||
apply_rules,
|
||||
homologate_doc_type,
|
||||
split_doc_tipo_y_numero_texto,
|
||||
)
|
||||
|
||||
|
||||
def test_homologate_doc_type_aliases() -> None:
|
||||
cases = {
|
||||
"Cedula": "CC",
|
||||
"C.C": "CC",
|
||||
"C C": "CC",
|
||||
"Registro Civil": "RC",
|
||||
"Salvoconducto": "SC",
|
||||
"TC": "RC",
|
||||
"C\u00e9dula de Extranjeria": "CE",
|
||||
}
|
||||
|
||||
for raw, expected in cases.items():
|
||||
assert homologate_doc_type(raw) == expected
|
||||
|
||||
|
||||
def test_split_doc_type_with_long_names() -> None:
|
||||
cases = {
|
||||
"Cedula 123.456": ("CC", "123456"),
|
||||
"Registro Civil: 001-23": ("RC", "00123"),
|
||||
"Permiso especial de permanencia 999": ("PE", "999"),
|
||||
"Permiso por proteccion Temporal / 555": ("PT", "555"),
|
||||
}
|
||||
|
||||
for raw, expected in cases.items():
|
||||
assert split_doc_tipo_y_numero_texto(raw) == expected
|
||||
|
||||
|
||||
def test_split_doc_type_with_abbreviations_and_noise() -> None:
|
||||
cases = {
|
||||
"C.C-123": ("CC", "123"),
|
||||
"TL_987": ("TI", "987"),
|
||||
"CE/1.234.567": ("CE", "1234567"),
|
||||
"T1 # 44": ("TI", "44"),
|
||||
}
|
||||
|
||||
for raw, expected in cases.items():
|
||||
assert split_doc_tipo_y_numero_texto(raw) == expected
|
||||
|
||||
|
||||
def test_split_unknown_type_preserves_clean_uppercase() -> None:
|
||||
assert split_doc_tipo_y_numero_texto("ABC Especial: 123-45") == (
|
||||
"ABC ESPECIAL",
|
||||
"12345",
|
||||
)
|
||||
|
||||
|
||||
def test_split_without_homologation_preserves_clean_type() -> None:
|
||||
assert split_doc_tipo_y_numero_texto(
|
||||
"Cedula 123.456",
|
||||
homologate=False,
|
||||
) == ("CEDULA", "123456")
|
||||
|
||||
|
||||
def test_apply_rules_homologates_type_column_only() -> None:
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"tipo": [
|
||||
"Cedula",
|
||||
"C.C",
|
||||
"Registro Civil",
|
||||
"Salvoconducto",
|
||||
"TC",
|
||||
"Extranjera",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result, log = apply_rules(df, {"tipo": ["doc_type_homologate"]})
|
||||
|
||||
assert result["tipo"].tolist() == ["CC", "CC", "RC", "SC", "RC", "CE"]
|
||||
assert any("Homologar tipo de documento" in line for line in log)
|
||||
|
||||
|
||||
def test_apply_rules_split_uses_homologation_when_selected() -> None:
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"documento": [
|
||||
"Cedula 123.456",
|
||||
"Registro Civil: 001-23",
|
||||
"ABC Especial: 123-45",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{"documento": ["doc_id_split_tipo_numero", "doc_type_homologate"]},
|
||||
)
|
||||
|
||||
assert result["documento_doc_tipo"].tolist() == ["CC", "RC", "ABC ESPECIAL"]
|
||||
assert result["documento_doc_numeros"].tolist() == ["123456", "00123", "12345"]
|
||||
assert result["documento"].tolist() == df["documento"].tolist()
|
||||
|
||||
|
||||
def test_apply_rules_split_can_skip_homologation() -> None:
|
||||
df = pd.DataFrame({"documento": ["Cedula 123.456"]})
|
||||
|
||||
result, _log = apply_rules(df, {"documento": ["doc_id_split_tipo_numero"]})
|
||||
|
||||
assert result["documento_doc_tipo"].tolist() == ["CEDULA"]
|
||||
assert result["documento_doc_numeros"].tolist() == ["123456"]
|
||||
|
||||
|
||||
def test_apply_rules_unified_date_rule_numeric_and_separated() -> None:
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"fecha": [
|
||||
"20260426",
|
||||
"26042026",
|
||||
"260426",
|
||||
"26/04/2026",
|
||||
"04/26/2026",
|
||||
"2026-04-26 10:30",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result, log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
|
||||
|
||||
assert result["fecha"].tolist() == [
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
]
|
||||
assert any("Normalizar fecha" in line for line in log)
|
||||
|
||||
|
||||
def test_apply_rules_unified_date_rule_text_months() -> None:
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"fecha": [
|
||||
"26-abril-2026",
|
||||
"26 abril 2026",
|
||||
"26/abr/2026",
|
||||
"abril 26 2026",
|
||||
"2026 abril 26",
|
||||
"15-sept-25",
|
||||
"07 setiembre 2024",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
|
||||
|
||||
assert result["fecha"].tolist() == [
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"15/09/2025",
|
||||
"07/09/2024",
|
||||
]
|
||||
|
||||
|
||||
def test_apply_rules_unified_date_spanish_abbr_month_with_time() -> None:
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"fecha": [
|
||||
"15/may./2024 13:44",
|
||||
"22/may./2024 11:09",
|
||||
"30/04/2024",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
|
||||
|
||||
assert result["fecha"].tolist() == [
|
||||
"15/05/2024",
|
||||
"22/05/2024",
|
||||
"30/04/2024",
|
||||
]
|
||||
|
||||
|
||||
def test_apply_rules_date_after_strip_special_spaces_glued_year_time() -> None:
|
||||
df = pd.DataFrame({"fecha": ["15/may./2024 13:44"]})
|
||||
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{"fecha": ["strip_special_spaces", "date_to_ddmmyyyy"]},
|
||||
)
|
||||
|
||||
assert result["fecha"].tolist() == ["15/05/2024"]
|
||||
|
||||
|
||||
def test_apply_rules_keep_date_seps_preserves_time_then_normalizes() -> None:
|
||||
df = pd.DataFrame({"fecha": ["30/04/2024 13:44"]})
|
||||
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{"fecha": ["strip_special_keep_date_seps", "date_to_ddmmyyyy"]},
|
||||
)
|
||||
|
||||
assert result["fecha"].tolist() == ["30/04/2024"]
|
||||
|
||||
|
||||
def test_apply_rules_us_style_date_comma_then_am_pm_strips_time() -> None:
|
||||
"""Formato tipo Excel/locale US: mes/día/año, coma y hora — solo fecha DD/MM/AAAA."""
|
||||
df = pd.DataFrame({"fecha": ["12/27/24, 12:00 AM"]})
|
||||
|
||||
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
|
||||
|
||||
assert result["fecha"].tolist() == ["27/12/2024"]
|
||||
|
||||
|
||||
def test_apply_rules_unified_date_rule_noise_and_unparseable_values() -> None:
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"fecha": [
|
||||
"**26_abril_2026++",
|
||||
"Fecha: 26-abril-2026",
|
||||
"xx26/04/2026yy",
|
||||
"no es fecha",
|
||||
"31-febrero-2026",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
|
||||
|
||||
assert result["fecha"].tolist() == [
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"26/04/2026",
|
||||
"no es fecha",
|
||||
"31-febrero-2026",
|
||||
]
|
||||
|
||||
|
||||
def test_apply_rules_cop_mod_moderadora_matches_year() -> None:
|
||||
df = pd.DataFrame(
|
||||
{"Fecha Servicio": ["01/01/2024"], "Importe": [4500]},
|
||||
)
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{"Importe": ["cop_mod_split"]},
|
||||
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
|
||||
cop_mod_config={"moderadora_by_year": {"2024": [4500, 18200, 47700]}},
|
||||
)
|
||||
assert result["Importe_moderadora"].tolist() == [4500]
|
||||
assert result["Importe_copago"].tolist() == [0]
|
||||
|
||||
|
||||
def test_apply_rules_cop_mod_non_moderadora_to_copago() -> None:
|
||||
df = pd.DataFrame(
|
||||
{"Fecha Servicio": ["01/01/2024"], "Importe": [4982]},
|
||||
)
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{"Importe": ["cop_mod_split"]},
|
||||
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
|
||||
cop_mod_config={"moderadora_by_year": {"2024": [4500, 18200, 47700]}},
|
||||
)
|
||||
assert result["Importe_copago"].tolist() == [4982]
|
||||
assert result["Importe_moderadora"].tolist() == [0]
|
||||
|
||||
|
||||
def test_apply_rules_cop_mod_two_phase_dirty_date() -> None:
|
||||
df = pd.DataFrame(
|
||||
{"Fecha Servicio": ["15/jun/2024"], "Importe": [4500]},
|
||||
)
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{
|
||||
"Fecha Servicio": ["date_to_ddmmyyyy"],
|
||||
"Importe": ["cop_mod_split"],
|
||||
},
|
||||
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
|
||||
cop_mod_config={"moderadora_by_year": {"2024": [4500, 18200, 47700]}},
|
||||
)
|
||||
assert result["Fecha Servicio"].iloc[0] == "15/06/2024"
|
||||
assert result["Importe_moderadora"].iloc[0] == 4500
|
||||
|
||||
|
||||
def test_apply_rules_cop_mod_year_not_in_table_goes_copago() -> None:
|
||||
df = pd.DataFrame(
|
||||
{"Fecha Servicio": ["01/01/2030"], "Importe": [4500]},
|
||||
)
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{"Importe": ["cop_mod_split"]},
|
||||
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
|
||||
cop_mod_config={"moderadora_by_year": {"2024": [4500]}},
|
||||
)
|
||||
assert result["Importe_copago"].tolist() == [4500]
|
||||
assert result["Importe_moderadora"].tolist() == [0]
|
||||
|
||||
|
||||
def test_apply_rules_cop_mod_null_amount_zeros() -> None:
|
||||
df = pd.DataFrame({"Fecha Servicio": ["01/01/2024"], "Importe": [pd.NA]})
|
||||
result, _log = apply_rules(
|
||||
df,
|
||||
{"Importe": ["cop_mod_split"]},
|
||||
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
|
||||
cop_mod_config={"moderadora_by_year": {"2024": [4500]}},
|
||||
)
|
||||
assert result["Importe_copago"].tolist() == [0]
|
||||
assert result["Importe_moderadora"].tolist() == [0]
|
||||
|
||||
|
||||
def test_apply_rules_nota_extract_one_line_sin_autorizacion() -> None:
|
||||
texto = (
|
||||
"Las retenciones son a nivel informativo no afecta el total a pagar. "
|
||||
"Paciente: 1063146603 - VILLA BARRIOS NORMA JUDITH Atención No: 63728; "
|
||||
"Contrato No. URGENCIAS. Valor en Letras: CIENTO OCHENTA Y SIETE MIL "
|
||||
"QUINIENTOS TREINTA Y SIETE PESOS Fecha ingreso 19/11/2023 fecha egreso 19/11/2023"
|
||||
)
|
||||
df = pd.DataFrame({"notas": [texto]})
|
||||
result, log = apply_rules(df, {"notas": ["nota_paciente_autorizacion"]})
|
||||
assert result["notas_nota_paciente_doc"].iloc[0] == "1063146603"
|
||||
assert pd.isna(result["notas_nota_autorizacion"].iloc[0])
|
||||
assert any("Extraer datos de notas" in line for line in log)
|
||||
|
||||
|
||||
def test_apply_rules_nota_extract_multiline_con_autorizacion() -> None:
|
||||
texto = (
|
||||
"Las retenciones son a nivel informativo no afecta el total a pagar.\n"
|
||||
"Paciente: 30662603 - LLORENTE HERNANDEZ CARMEN LUCIA Atención No: 69426;\n"
|
||||
"Autorización No. 227490546; Contrato No. 118. Valor en Letras: CIENTO NOVENTA Y OCHO\n"
|
||||
"MIL SEISCIENTOS DIEZ PESOS Fecha ingreso 23/01/2024 fecha egreso 23/01/2024"
|
||||
)
|
||||
df = pd.DataFrame({"notas": [texto]})
|
||||
result, _log = apply_rules(df, {"notas": ["nota_paciente_autorizacion"]})
|
||||
assert result["notas_nota_paciente_doc"].iloc[0] == "30662603"
|
||||
assert result["notas_nota_autorizacion"].iloc[0] == "227490546"
|
||||
|
||||
|
||||
def test_apply_rules_nota_extract_sin_patrones() -> None:
|
||||
df = pd.DataFrame({"notas": ["sin datos relevantes aquí"]})
|
||||
result, _log = apply_rules(df, {"notas": ["nota_paciente_autorizacion"]})
|
||||
assert pd.isna(result["notas_nota_paciente_doc"].iloc[0])
|
||||
assert pd.isna(result["notas_nota_autorizacion"].iloc[0])
|
||||
@@ -1,52 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from application.csv_manager import CSVManager, sanitize_csv_cell_value
|
||||
|
||||
|
||||
def test_sanitize_collapses_newlines() -> None:
|
||||
assert sanitize_csv_cell_value("a\nb") == "a b"
|
||||
assert sanitize_csv_cell_value("a\r\nb") == "a b"
|
||||
assert sanitize_csv_cell_value(" x\v\fy ") == "x y"
|
||||
|
||||
|
||||
def test_sanitize_unicode_line_separators() -> None:
|
||||
assert sanitize_csv_cell_value("a\u2028b\u2029c") == "a b c"
|
||||
assert sanitize_csv_cell_value("a\x85b") == "a b"
|
||||
|
||||
|
||||
def test_sanitize_preserves_none_and_scalars() -> None:
|
||||
assert sanitize_csv_cell_value(None) is None
|
||||
assert sanitize_csv_cell_value(42) == 42
|
||||
assert sanitize_csv_cell_value(3.14) == 3.14
|
||||
assert sanitize_csv_cell_value(False) is False
|
||||
assert sanitize_csv_cell_value(True) is True
|
||||
|
||||
|
||||
def test_sanitize_plain_string_unchanged_identity() -> None:
|
||||
s = "sin saltos"
|
||||
assert sanitize_csv_cell_value(s) is s
|
||||
|
||||
|
||||
def test_append_rows_one_physical_line_per_record() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "batch.csv"
|
||||
mgr = CSVManager()
|
||||
header = ["radicado", "descripcion"]
|
||||
rows = [
|
||||
{
|
||||
"radicado": "235662283",
|
||||
"descripcion": (
|
||||
'"Las retenciones son a nivel informativo.\n'
|
||||
"Paciente: 1010079549 - HERAZO\n"
|
||||
'Contrato No. URGENCIAS."'
|
||||
),
|
||||
},
|
||||
{"radicado": "2", "descripcion": "ok"},
|
||||
]
|
||||
mgr.append_rows(str(path), rows, header)
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
physical_lines = text.splitlines()
|
||||
assert len(physical_lines) == 3
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.csv_utils import merge_verbatim_long_strings, set_at_path
|
||||
|
||||
|
||||
def test_merge_replaces_truncated_string_same_path() -> None:
|
||||
raw = {
|
||||
"Notas": (
|
||||
"Las retenciones son informativas. Paciente: 1 - NOMBRE "
|
||||
"Contrato No. X. Valor en Letras: MIL PESOS Fecha ingreso 01/01/2024"
|
||||
),
|
||||
}
|
||||
norm = {"Notas": "Las retenciones son informativas. Paciente: 1 - NOMBRE "}
|
||||
merge_verbatim_long_strings(raw, norm, ["Notas"])
|
||||
assert norm["Notas"] == raw["Notas"]
|
||||
|
||||
|
||||
def test_merge_nested_path() -> None:
|
||||
raw = {"cabecera": {"Notas": "A" * 200}}
|
||||
norm = {"cabecera": {"Notas": "A" * 40}}
|
||||
merge_verbatim_long_strings(raw, norm, ["cabecera/Notas"])
|
||||
assert norm["cabecera"]["Notas"] == "A" * 200
|
||||
|
||||
|
||||
def test_merge_resolves_by_unique_last_segment() -> None:
|
||||
raw = {"Notas": "texto completo desde raíz cuando el normalizado usa cabecera/Notas"}
|
||||
norm = {"cabecera": {"Notas": "texto completo"}}
|
||||
merge_verbatim_long_strings(raw, norm, ["cabecera/Notas"])
|
||||
assert norm["cabecera"]["Notas"] == raw["Notas"]
|
||||
|
||||
|
||||
def test_merge_skips_ambiguous_same_last_segment() -> None:
|
||||
raw = {
|
||||
"a": {"Notas": "primera nota " + "x" * 50},
|
||||
"b": {"Notas": "segunda nota " + "y" * 50},
|
||||
}
|
||||
norm = {"cabecera": {"Notas": "primera nota"}}
|
||||
merge_verbatim_long_strings(raw, norm, ["cabecera/Notas"])
|
||||
assert norm["cabecera"]["Notas"] == "primera nota"
|
||||
|
||||
|
||||
def test_merge_skips_when_normalized_not_prefix_of_raw() -> None:
|
||||
raw = {"Notas": "completamente distinto " + "z" * 80}
|
||||
norm = {"Notas": "otro texto corto"}
|
||||
merge_verbatim_long_strings(raw, norm, ["Notas"])
|
||||
assert norm["Notas"] == "otro texto corto"
|
||||
|
||||
|
||||
def test_merge_skips_paths_with_wildcard() -> None:
|
||||
raw = {"items": [{"x": "long " * 20}]}
|
||||
norm = {"items": [{"x": "long "}]}
|
||||
merge_verbatim_long_strings(raw, norm, ["items/*/x"])
|
||||
assert norm["items"][0]["x"] == "long "
|
||||
|
||||
|
||||
def test_set_at_path_creates_intermediates() -> None:
|
||||
root: dict = {}
|
||||
set_at_path(root, "a/b/c", 1)
|
||||
assert root == {"a": {"b": {"c": 1}}}
|
||||
Reference in New Issue
Block a user