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:
erik
2026-07-03 18:33:50 -05:00
parent 78df190d27
commit b55ad74539
75 changed files with 1699 additions and 8514 deletions
+189 -85
View File
@@ -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.
-137
View File
@@ -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")))
-101
View File
@@ -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
-281
View File
@@ -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
-254
View File
@@ -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 ","
-448
View File
@@ -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)
-107
View File
@@ -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
-97
View File
@@ -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
+77
View File
@@ -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:
-5
View File
@@ -1,5 +0,0 @@
"""Módulo de GUI."""
from gui.view import InvoiceAppView
from gui.styles import AppStyles
__all__ = ["InvoiceAppView", "AppStyles"]
-1410
View File
File diff suppressed because it is too large Load Diff
-386
View File
@@ -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))
-58
View File
@@ -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),
}
-18
View File
@@ -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
-574
View File
@@ -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)
-351
View File
@@ -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
-227
View File
@@ -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)
-478
View File
@@ -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)
-304
View File
@@ -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)
-314
View File
@@ -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
View File
@@ -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
View File
@@ -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()
-49
View File
@@ -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
-51
View File
@@ -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
View File
@@ -1 +0,0 @@
-159
View File
@@ -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()
-187
View File
@@ -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
View File
@@ -1 +0,0 @@
-54
View File
@@ -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
+15
View File
@@ -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"]
+29
View File
@@ -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}`
+1
View File
@@ -0,0 +1 @@
"""API central opcional para enrutar a los microservicios."""
+110
View File
@@ -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"),
)
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.6
httpx==0.28.1
uvicorn[standard]==0.32.1
+21
View File
@@ -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"]
+32
View File
@@ -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`
+1
View File
@@ -0,0 +1 @@
"""Servicio REST de limpieza."""
+200
View File
@@ -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)
)
)
+7
View File
@@ -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
+22
View File
@@ -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"]
+35
View File
@@ -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`
+1
View File
@@ -0,0 +1 @@
"""Servicio REST de extraccion."""
+327
View File
@@ -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)
+170
View File
@@ -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
+12
View File
@@ -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
-13
View File
@@ -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)
-347
View File
@@ -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])
-52
View File
@@ -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
-59
View File
@@ -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}}}