Refactor digitador bot and add runtime monitoring
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,8 @@
|
||||
BOT_API_URL=http://localhost:8189/bot-digitador
|
||||
BOT_API_URL=https://pys1.builker.xyz/bot-digitador
|
||||
BOT_API_TOKEN=dev-token-change-me
|
||||
BOT_WORKER_ID=
|
||||
DIGITADOR_APP_PATH=C:\Users\Pysctas185\Documents\Integracion Total.lnk
|
||||
# Opcional. Si se omite, usa Documents del usuario que ejecuta el agent.
|
||||
DIGITADOR_APP_PATH=%USERPROFILE%\Documents\IT SaludTotal.lnk
|
||||
MINIO_ENDPOINT=https://s3.builker.xyz
|
||||
MINIO_BUCKET=pys-cuentas-medicas
|
||||
MINIO_ANONYMOUS=1
|
||||
|
||||
@@ -6,6 +6,10 @@ Vive en este monorepo bajo `bots/` (no es un microservicio de `services/`).
|
||||
- **No descarga PDFs.** Solo digitación UI + reportes CSV/log.
|
||||
- **No expone puertos.** Solo HTTP saliente al gateway IPSExtractor y PutObject a MinIO.
|
||||
- La API de control se espera en `…/bot-digitador` (mismo patrón que `bot_descarga`).
|
||||
- La selección del tipo documental replica `bot_saludtotal_6.py`: abre el combo,
|
||||
vuelve a `HOME` y usa secuencias de teclado estables en lugar de coordenadas.
|
||||
- El PC trabajador conserva el visor flotante pequeño del bot original, siempre
|
||||
visible, con progreso, resultados, radicado, velocidad y duración en vivo.
|
||||
|
||||
## Requisitos en la PC
|
||||
|
||||
@@ -38,6 +42,9 @@ Configura URL API (ej. `http://localhost:8189/bot-digitador`), Worker ID, token
|
||||
ruta de Integración Total (`DIGITADOR_APP_PATH`). Pulsa **Conectar agent**.
|
||||
Queda idle hasta que el desktop asigne un job CSV.
|
||||
|
||||
Al aceptar un job se validan primero las nueve plantillas. Si falta alguna, la
|
||||
corrida termina con un error explícito y no manipula Integración Total.
|
||||
|
||||
### Autoarranque en Windows
|
||||
|
||||
1. Marca **Iniciar con Windows**.
|
||||
@@ -73,6 +80,15 @@ PC bot (esta carpeta) ────────┘ HTTP outbound
|
||||
└─ pywinauto → Integración Total
|
||||
```
|
||||
|
||||
El código está separado por responsabilidad:
|
||||
|
||||
- `core/config.py`: secuencias, coordenadas y recursos requeridos.
|
||||
- `core/csv_rules.py`: CSV, fechas, valores monetarios y tipos documentales.
|
||||
- `core/application.py`: interacción de teclado/ratón con Windows.
|
||||
- `core/vision.py`: validación del inventario OpenCV.
|
||||
- `core/reports.py`: `RESULTADOS`, `OMITIDOS` y log.
|
||||
- `core/digitador_service.py`: orquestación, cancelación y eventos remotos.
|
||||
|
||||
## Variables
|
||||
|
||||
Ver [`.env.example`](.env.example):
|
||||
@@ -81,7 +97,7 @@ Ver [`.env.example`](.env.example):
|
||||
|----------|-----|
|
||||
| `BOT_API_URL` | Base del API vía gateway (`…/bot-digitador`) |
|
||||
| `BOT_API_TOKEN` | Bearer compartido con el servicio |
|
||||
| `DIGITADOR_APP_PATH` | Acceso directo / ejecutable de Integración Total |
|
||||
| `DIGITADOR_APP_PATH` | Acceso directo / ejecutable de Integración Total. Por defecto se resuelve como `%USERPROFILE%\Documents\IT SaludTotal.lnk` para cada usuario; puede sobrescribirse por worker. |
|
||||
| `MINIO_*` | Bucket de reportes; por defecto anónimo |
|
||||
|
||||
## CSV de entrada
|
||||
@@ -90,3 +106,24 @@ Columnas requeridas: `radicado`, `documento`, `ingreso`, `total_cargos`,
|
||||
`autorizacion`, `tipo_documento`, `tipo_servicio`, `copago`, `cuota_moderadora`.
|
||||
|
||||
Acepta delimitador `,` o `|` (auto-detección).
|
||||
|
||||
## Entorno visual soportado
|
||||
|
||||
La automatización se calibra para el entorno operativo actual: Windows,
|
||||
resolución 1920×1080, escala de pantalla al 100 % e Integración Total visible y
|
||||
maximizado. No deben usarse sesiones bloqueadas o minimizadas mientras corre un job.
|
||||
|
||||
## Construcción y publicación
|
||||
|
||||
Con las nueve plantillas presentes:
|
||||
|
||||
```bash
|
||||
python scripts/package_bot.py
|
||||
python scripts/publish_bot.py --build-only
|
||||
python scripts/publish_bot.py
|
||||
```
|
||||
|
||||
El empaquetador excluye `.env`, cachés, datos, temporales y reportes. La
|
||||
publicación usa `MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` y deja el artefacto en:
|
||||
|
||||
`apps-versions/BotDigitadorPYSCuentasMedicas/latest/bot_digitador.zip`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Interacción de bajo nivel con la aplicación Windows Integración Total."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from pywinauto import mouse
|
||||
|
||||
from bot_digitador.core.config import DOC_KEY_SEQUENCES
|
||||
|
||||
|
||||
def click_abs(window, x: int, y: int) -> None:
|
||||
try:
|
||||
window.wrapper_object().set_focus()
|
||||
except Exception:
|
||||
pass
|
||||
ctypes.windll.user32.SetCursorPos(x, y)
|
||||
time.sleep(0.05)
|
||||
ctypes.windll.user32.SetCursorPos(x + 1, y)
|
||||
time.sleep(0.05)
|
||||
ctypes.windll.user32.SetCursorPos(x, y)
|
||||
time.sleep(0.2)
|
||||
mouse.click(button="left", coords=(x, y))
|
||||
|
||||
|
||||
def select_document_type(
|
||||
window,
|
||||
document_type: str,
|
||||
*,
|
||||
log: Callable[[str], None],
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
click: Callable[[object, int, int], None] = click_abs,
|
||||
) -> bool:
|
||||
"""Replica la selección estable de bot_saludtotal_6.py mediante teclado."""
|
||||
sequence = DOC_KEY_SEQUENCES.get(document_type)
|
||||
if not sequence:
|
||||
log(f"Advertencia: No hay secuencia configurada para {document_type}.")
|
||||
return False
|
||||
try:
|
||||
log(f"Seleccionando tipo {document_type} con teclas: {sequence.upper()}")
|
||||
click(window, 729, 279)
|
||||
sleep(0.4)
|
||||
window.wrapper_object().type_keys("{HOME}", set_foreground=True)
|
||||
sleep(0.2)
|
||||
for key in sequence:
|
||||
window.wrapper_object().type_keys(key, set_foreground=True)
|
||||
sleep(0.15)
|
||||
sleep(0.3)
|
||||
window.wrapper_object().type_keys("{ENTER}", set_foreground=True)
|
||||
sleep(0.4)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log(f"Error seleccionando el tipo {document_type} por teclado: {exc}")
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Configuración funcional de Integración Total y recursos visuales requeridos."""
|
||||
from __future__ import annotations
|
||||
|
||||
from bot_digitador.core.coords import * # noqa: F403 - compatibilidad pública
|
||||
|
||||
|
||||
# Secuencias del bot_saludtotal_6.py. Las opciones que empiezan por C se
|
||||
# recorren pulsando C varias veces después de llevar el combo a HOME.
|
||||
DOC_KEY_SEQUENCES = {
|
||||
"CD": "c",
|
||||
"CE": "cc",
|
||||
"CC": "ccc",
|
||||
"CN": "cccc",
|
||||
"TI": "ti",
|
||||
"RC": "rc",
|
||||
"AS": "as",
|
||||
"ME": "me",
|
||||
"NI": "ni",
|
||||
"NU": "nu",
|
||||
"PS": "ps",
|
||||
"PE": "pe",
|
||||
"PT": "pt",
|
||||
"PJ": "pj",
|
||||
"SC": "sc",
|
||||
}
|
||||
|
||||
REQUIRED_TEMPLATE_NAMES = (
|
||||
TEMPLATE_ERROR, # noqa: F405
|
||||
TEMPLATE_SUCCESS, # noqa: F405
|
||||
TEMPLATE_BIEN, # noqa: F405
|
||||
TEMPLATE_DIFERENCIAS, # noqa: F405
|
||||
TEMPLATE_DIFERENCIAS_CERRADO, # noqa: F405
|
||||
TEMPLATE_FECHA_VACIA_SLASH, # noqa: F405
|
||||
TEMPLATE_FECHA_VACIA_BLANCO, # noqa: F405
|
||||
TEMPLATE_POPUP_NOMBRES, # noqa: F405
|
||||
TEMPLATE_RADICADO_EXISTENTE, # noqa: F405
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DOC_COORDS = {
|
||||
"CC": (796, 343),
|
||||
@@ -39,10 +40,13 @@ SERVICES_COORDS = {
|
||||
|
||||
COORD_CLOSE_ERROR = (1223, 704)
|
||||
|
||||
DEFAULT_APP_PATH = os.getenv(
|
||||
"DIGITADOR_APP_PATH",
|
||||
r"C:\Users\Pysctas185\Documents\Integracion Total.lnk",
|
||||
)
|
||||
DEFAULT_APP_PATH = str(Path.home() / "Documents" / "IT SaludTotal.lnk")
|
||||
|
||||
|
||||
def resolve_app_path(value: str | None = None) -> str:
|
||||
"""Resuelve el acceso directo para el usuario que ejecuta este worker."""
|
||||
configured = value or os.getenv("DIGITADOR_APP_PATH") or DEFAULT_APP_PATH
|
||||
return os.path.abspath(os.path.expanduser(os.path.expandvars(configured)))
|
||||
PROCESS_NAME = "SaludTotal.Health.WinClient.TotalIntegration.exe"
|
||||
REINICIO_CADA = 6
|
||||
TIMEOUT_REGISTRO = 120
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Lectura de CSV y reglas puras de transformación del Bot Digitador."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
REQUIRED_COLUMNS = {
|
||||
"radicado",
|
||||
"documento",
|
||||
"ingreso",
|
||||
"total_cargos",
|
||||
"autorizacion",
|
||||
"tipo_documento",
|
||||
"tipo_servicio",
|
||||
"copago",
|
||||
"cuota_moderadora",
|
||||
}
|
||||
|
||||
|
||||
def detect_csv_delimiter(csv_path: str, preferred: Optional[str] = None) -> str:
|
||||
candidates = [preferred] if preferred in (",", "|") else []
|
||||
candidates.extend(d for d in (",", "|") if d not in candidates)
|
||||
for delimiter in candidates:
|
||||
try:
|
||||
with open(csv_path, newline="", encoding="utf-8-sig") as source:
|
||||
headers = [h.strip() for h in (csv.DictReader(source, delimiter=delimiter).fieldnames or [])]
|
||||
if REQUIRED_COLUMNS.issubset(headers):
|
||||
return delimiter
|
||||
except Exception:
|
||||
continue
|
||||
return preferred if preferred in (",", "|") else "|"
|
||||
|
||||
|
||||
def load_csv_rows(csv_path: str, delimiter: str) -> List[Dict[str, str]]:
|
||||
with open(csv_path, newline="", encoding="utf-8-sig") as source:
|
||||
reader = csv.DictReader(source, delimiter=delimiter)
|
||||
headers = [h.strip() for h in (reader.fieldnames or [])]
|
||||
reader.fieldnames = headers
|
||||
if not REQUIRED_COLUMNS.issubset(headers):
|
||||
missing = REQUIRED_COLUMNS - set(headers)
|
||||
raise ValueError(
|
||||
"El CSV no tiene las columnas requeridas.\n"
|
||||
f"Faltan: {', '.join(sorted(missing))}\n"
|
||||
"Verifique si el delimitador es correcto."
|
||||
)
|
||||
return [
|
||||
{k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()}
|
||||
for row in reader
|
||||
]
|
||||
|
||||
|
||||
def convert_date(value: object, date_format: str) -> Tuple[str, bool]:
|
||||
if not value:
|
||||
return "", True
|
||||
formats = ["%d/%m/%Y", "%m/%d/%Y", "%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%m-%d-%Y"]
|
||||
preferred = "%d/%m/%Y" if date_format == "dd/mm/yyyy" else "%m/%d/%Y"
|
||||
formats.remove(preferred)
|
||||
formats.insert(0, preferred)
|
||||
cleaned = str(value).strip()
|
||||
for candidate in formats:
|
||||
try:
|
||||
return datetime.strptime(cleaned, candidate).strftime("%m%d%Y"), True
|
||||
except ValueError:
|
||||
continue
|
||||
return str(value), False
|
||||
|
||||
|
||||
def parse_money(value: object) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
text = str(value).strip().replace("$", "").replace(" ", "")
|
||||
if not text:
|
||||
return 0.0
|
||||
if "." in text and "," not in text:
|
||||
parts = text.split(".")
|
||||
if len(parts[-1]) == 3:
|
||||
text = text.replace(".", "")
|
||||
try:
|
||||
return float(text.replace(",", "."))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def format_money_for_input(value: object) -> str:
|
||||
number = parse_money(value)
|
||||
return str(int(number)) if number == int(number) else str(number).replace(".", ",")
|
||||
|
||||
|
||||
def valid_document_types(value: object) -> List[str]:
|
||||
document = str(value).strip()
|
||||
if not document:
|
||||
return []
|
||||
length = len(document)
|
||||
numeric = document.isdigit()
|
||||
result: List[str] = []
|
||||
rules = (
|
||||
(length in (10, 12), "AS"),
|
||||
(3 <= length <= 11, "CD"),
|
||||
(3 <= length <= 7 and numeric, "CE"),
|
||||
(3 <= length <= 10 and length != 9 and numeric, "CC"),
|
||||
(9 <= length <= 14 and numeric, "CN"),
|
||||
(length in (10, 12), "ME"),
|
||||
(length == 9 and numeric, "NI"),
|
||||
(length == 9 and numeric, "NU"),
|
||||
(3 <= length <= 16, "PS"),
|
||||
(length == 15 and numeric, "PE"),
|
||||
(7 <= length <= 8 and numeric, "PT"),
|
||||
(length == 9 and numeric, "PJ"),
|
||||
(length == 10 and numeric, "RC"),
|
||||
(1 <= length <= 8 and numeric, "SC"),
|
||||
(length == 10 and numeric, "TI"),
|
||||
)
|
||||
for matches, name in rules:
|
||||
if matches:
|
||||
result.append(name)
|
||||
return result
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import ctypes
|
||||
import os
|
||||
import time
|
||||
@@ -11,13 +10,11 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from pywinauto import mouse
|
||||
from pywinauto.application import Application
|
||||
|
||||
from bot_digitador.core.coords import (
|
||||
from bot_digitador.core.config import (
|
||||
COORD_CLOSE_ERROR,
|
||||
DEFAULT_APP_PATH,
|
||||
DOC_COORDS,
|
||||
DOC_KEY_SEQUENCES,
|
||||
DOC_STRATEGY,
|
||||
PROCESS_NAME,
|
||||
REINICIO_CADA,
|
||||
@@ -32,7 +29,19 @@ from bot_digitador.core.coords import (
|
||||
TEMPLATE_RADICADO_EXISTENTE,
|
||||
TEMPLATE_SUCCESS,
|
||||
TIMEOUT_REGISTRO,
|
||||
resolve_app_path,
|
||||
)
|
||||
from bot_digitador.core.application import click_abs, select_document_type
|
||||
from bot_digitador.core.csv_rules import (
|
||||
convert_date as convert_date_value,
|
||||
detect_csv_delimiter,
|
||||
format_money_for_input,
|
||||
load_csv_rows,
|
||||
parse_money,
|
||||
valid_document_types,
|
||||
)
|
||||
from bot_digitador.core.reports import write_reports
|
||||
from bot_digitador.core.vision import validate_templates
|
||||
from bot_digitador.models.events import DigitadorEvent, create_event
|
||||
|
||||
try:
|
||||
@@ -51,18 +60,6 @@ except Exception:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
REQUIRED_COLUMNS = {
|
||||
"radicado",
|
||||
"documento",
|
||||
"ingreso",
|
||||
"total_cargos",
|
||||
"autorizacion",
|
||||
"tipo_documento",
|
||||
"tipo_servicio",
|
||||
"copago",
|
||||
"cuota_moderadora",
|
||||
}
|
||||
|
||||
EventCallback = Callable[[DigitadorEvent], None]
|
||||
CancelCallback = Callable[[], bool]
|
||||
|
||||
@@ -78,53 +75,16 @@ class DigitadorRunSummary:
|
||||
cancelled: bool = False
|
||||
fatal_error: Optional[str] = None
|
||||
output_dir: str = ""
|
||||
reinicios: int = 0
|
||||
elapsed_seconds: int = 0
|
||||
speed_per_minute: float = 0.0
|
||||
resultados: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def click_abs(window, x: int, y: int) -> None:
|
||||
try:
|
||||
window.wrapper_object().set_focus()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ctypes.windll.user32.SetCursorPos(x, y)
|
||||
time.sleep(0.05)
|
||||
ctypes.windll.user32.SetCursorPos(x + 1, y)
|
||||
time.sleep(0.05)
|
||||
ctypes.windll.user32.SetCursorPos(x, y)
|
||||
|
||||
time.sleep(0.2)
|
||||
mouse.click(button="left", coords=(x, y))
|
||||
|
||||
|
||||
def _default_template_dir() -> str:
|
||||
return str(Path(__file__).resolve().parents[2] / "template")
|
||||
|
||||
|
||||
def detect_csv_delimiter(csv_path: str, preferred: Optional[str] = None) -> str:
|
||||
"""Detecta delimitador (, o |) mirando cabeceras requeridas."""
|
||||
candidates: List[str] = []
|
||||
if preferred in (",", "|"):
|
||||
candidates.append(preferred)
|
||||
for d in (",", "|"):
|
||||
if d not in candidates:
|
||||
candidates.append(d)
|
||||
|
||||
for delim in candidates:
|
||||
try:
|
||||
with open(csv_path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f, delimiter=delim)
|
||||
headers = [h.strip() for h in (reader.fieldnames or [])]
|
||||
if REQUIRED_COLUMNS.issubset(set(headers)):
|
||||
return delim
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if preferred in (",", "|"):
|
||||
return preferred
|
||||
return "|"
|
||||
|
||||
|
||||
class DigitadorService:
|
||||
"""
|
||||
Servicio headless de digitación en Integración Total.
|
||||
@@ -152,11 +112,7 @@ class DigitadorService:
|
||||
self.date_format = date_format
|
||||
self.delimiter = delimiter
|
||||
self.auto_detect_delimiter = auto_detect_delimiter
|
||||
self.app_path = (
|
||||
app_path
|
||||
or os.getenv("DIGITADOR_APP_PATH")
|
||||
or DEFAULT_APP_PATH
|
||||
)
|
||||
self.app_path = resolve_app_path(app_path)
|
||||
self.reinicio_cada = reinicio_cada
|
||||
self.timeout_registro = timeout_registro
|
||||
self.event_callback = event_callback
|
||||
@@ -176,6 +132,8 @@ class DigitadorService:
|
||||
self.timeouts = 0
|
||||
self.reinicios = 0
|
||||
self.registro_actual = "-"
|
||||
self.estado_actual = "Listo"
|
||||
self.ultimo_evento = "-"
|
||||
self.inicio_proceso: Optional[float] = None
|
||||
self.inicio_registro: Optional[float] = None
|
||||
|
||||
@@ -239,13 +197,29 @@ class DigitadorService:
|
||||
total: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
elapsed_seconds = int(time.time() - self.inicio_proceso) if self.inicio_proceso else 0
|
||||
processed = self.procesados_ok + self.errores + self.existentes
|
||||
minutes = max(elapsed_seconds / 60, 0.01) if elapsed_seconds else 0
|
||||
overlay = {
|
||||
"estado": self.estado_actual,
|
||||
"ultimo": self.ultimo_evento,
|
||||
"processed": processed,
|
||||
"exitosos": self.procesados_ok,
|
||||
"errores": self.errores,
|
||||
"omitidos": self.omitidos,
|
||||
"existentes": self.existentes,
|
||||
"reinicios": self.reinicios,
|
||||
"radicado_actual": self.registro_actual,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"speed_per_minute": round(processed / minutes, 2) if minutes else 0,
|
||||
}
|
||||
event = create_event(
|
||||
event_type,
|
||||
message,
|
||||
radicado=radicado,
|
||||
index=index,
|
||||
total=total,
|
||||
metadata=metadata,
|
||||
metadata={**overlay, **(metadata or {})},
|
||||
)
|
||||
if self.event_callback:
|
||||
try:
|
||||
@@ -253,6 +227,12 @@ class DigitadorService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_status(self, status: str, last: str, *, emit: bool = True) -> None:
|
||||
self.estado_actual = status
|
||||
self.ultimo_evento = last
|
||||
if emit:
|
||||
self._emit("status", last, radicado=str(self.registro_actual or ""))
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
full_msg = f"[{timestamp}] {message}"
|
||||
@@ -342,38 +322,13 @@ class DigitadorService:
|
||||
|
||||
def save_reports(self) -> None:
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
log_filename = self.ruta_reporte("ejecucion_bot.log")
|
||||
with open(log_filename, "a", encoding="utf-8") as f:
|
||||
for entry in self.log_entries:
|
||||
f.write(entry + "\n")
|
||||
write_reports(
|
||||
self.output_dir,
|
||||
log_entries=self.log_entries,
|
||||
omitted_records=self.omitted_records,
|
||||
results=self.resultados,
|
||||
)
|
||||
self.log_entries = []
|
||||
|
||||
if self.omitted_records:
|
||||
omitted_filename = self.ruta_reporte("OMITIDOS.csv")
|
||||
fieldnames: List[str] = []
|
||||
for registro in self.omitted_records:
|
||||
for key in registro.keys():
|
||||
if key not in fieldnames:
|
||||
fieldnames.append(key)
|
||||
|
||||
with open(omitted_filename, "w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(self.omitted_records)
|
||||
|
||||
if self.resultados:
|
||||
resultados_filename = self.ruta_reporte("RESULTADOS.csv")
|
||||
with open(resultados_filename, "w", newline="", encoding="utf-8-sig") as f:
|
||||
fieldnames = [
|
||||
"radicado", "estado", "tipo_error", "motivo", "detalle",
|
||||
"fecha_hora", "duracion_segundos", "accion_bot",
|
||||
]
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(self.resultados)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error al guardar reportes: {e}")
|
||||
|
||||
@@ -381,108 +336,25 @@ class DigitadorService:
|
||||
# Fechas / valores / docs
|
||||
# ------------------------------------------------------------------
|
||||
def convert_date(self, date_str) -> str:
|
||||
if not date_str:
|
||||
return ""
|
||||
|
||||
input_formats = [
|
||||
"%d/%m/%Y",
|
||||
"%m/%d/%Y",
|
||||
"%Y-%m-%d",
|
||||
"%Y/%m/%d",
|
||||
"%d-%m-%Y",
|
||||
"%m-%d-%Y",
|
||||
]
|
||||
|
||||
user_fmt = "%d/%m/%Y" if self.date_format == "dd/mm/yyyy" else "%m/%d/%Y"
|
||||
|
||||
if user_fmt in input_formats:
|
||||
input_formats.remove(user_fmt)
|
||||
input_formats.insert(0, user_fmt)
|
||||
|
||||
date_clean = str(date_str).strip()
|
||||
|
||||
for fmt in input_formats:
|
||||
try:
|
||||
dt_obj = datetime.strptime(date_clean, fmt)
|
||||
return dt_obj.strftime("%m%d%Y")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
self.log(f"ADVERTENCIA: No se pudo convertir la fecha '{date_str}'. Se usará tal cual.")
|
||||
return date_str
|
||||
converted, valid = convert_date_value(date_str, self.date_format)
|
||||
if not valid:
|
||||
self.log(f"ADVERTENCIA: No se pudo convertir la fecha '{date_str}'. Se usará tal cual.")
|
||||
return converted
|
||||
|
||||
def limpiar_valor_monetario(self, valor) -> float:
|
||||
if valor is None:
|
||||
return 0.0
|
||||
|
||||
texto = str(valor).strip()
|
||||
if texto == "":
|
||||
return 0.0
|
||||
|
||||
texto = texto.replace("$", "").replace(" ", "")
|
||||
|
||||
if "." in texto and "," not in texto:
|
||||
partes = texto.split(".")
|
||||
if len(partes[-1]) == 3:
|
||||
texto = texto.replace(".", "")
|
||||
|
||||
texto = texto.replace(",", ".")
|
||||
|
||||
try:
|
||||
return float(texto)
|
||||
except Exception:
|
||||
return 0.0
|
||||
return parse_money(valor)
|
||||
|
||||
def formatear_valor_para_digitar(self, valor) -> str:
|
||||
numero = self.limpiar_valor_monetario(valor)
|
||||
if numero == int(numero):
|
||||
return str(int(numero))
|
||||
return str(numero).replace(".", ",")
|
||||
return format_money_for_input(valor)
|
||||
|
||||
def valor_mayor_a_cero(self, valor) -> bool:
|
||||
return self.limpiar_valor_monetario(valor) > 0
|
||||
|
||||
def get_valid_doc_types(self, doc_value) -> List[str]:
|
||||
doc_value = str(doc_value).strip()
|
||||
if not doc_value:
|
||||
return []
|
||||
return valid_document_types(doc_value)
|
||||
|
||||
length = len(doc_value)
|
||||
is_numeric = doc_value.isdigit()
|
||||
valid_types: List[str] = []
|
||||
|
||||
if length in [10, 12]:
|
||||
valid_types.append("AS")
|
||||
if 3 <= length <= 11:
|
||||
valid_types.append("CD")
|
||||
if 3 <= length <= 7 and is_numeric:
|
||||
valid_types.append("CE")
|
||||
if 3 <= length <= 10 and length != 9 and is_numeric:
|
||||
valid_types.append("CC")
|
||||
if 9 <= length <= 14 and is_numeric:
|
||||
valid_types.append("CN")
|
||||
if length in [10, 12]:
|
||||
valid_types.append("ME")
|
||||
if length == 9 and is_numeric:
|
||||
valid_types.append("NI")
|
||||
if length == 9 and is_numeric:
|
||||
valid_types.append("NU")
|
||||
if 3 <= length <= 16:
|
||||
valid_types.append("PS")
|
||||
if length == 15 and is_numeric:
|
||||
valid_types.append("PE")
|
||||
if 7 <= length <= 8 and is_numeric:
|
||||
valid_types.append("PT")
|
||||
if length == 9 and is_numeric:
|
||||
valid_types.append("PJ")
|
||||
if length == 10 and is_numeric:
|
||||
valid_types.append("RC")
|
||||
if 1 <= length <= 8 and is_numeric:
|
||||
valid_types.append("SC")
|
||||
if length == 10 and is_numeric:
|
||||
valid_types.append("TI")
|
||||
|
||||
return valid_types
|
||||
def seleccionar_tipo_documento(self, window, tipo_doc: str) -> bool:
|
||||
return select_document_type(window, tipo_doc, log=self.log)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OpenCV
|
||||
@@ -731,6 +603,7 @@ class DigitadorService:
|
||||
)
|
||||
|
||||
self.existentes += 1
|
||||
self._set_status("Existente", "Radicado existente", emit=False)
|
||||
self.log(f"RADICADO EXISTENTE: {val_radicado}. Se registra y vuelve al menú principal.")
|
||||
self._emit("radicado_skipped", f"Radicado existente: {val_radicado}", radicado=val_radicado)
|
||||
self.save_reports()
|
||||
@@ -743,6 +616,7 @@ class DigitadorService:
|
||||
|
||||
self.first_time = True
|
||||
self.reinicios += 1
|
||||
self._set_status("Reinicio", "Menú principal", emit=False)
|
||||
|
||||
def restart_bot(
|
||||
self,
|
||||
@@ -773,6 +647,7 @@ class DigitadorService:
|
||||
|
||||
self.first_time = True
|
||||
self.reinicios += 1
|
||||
self._set_status("Reinicio", "Flujo restablecido", emit=False)
|
||||
self.log("Bot reiniciado exitosamente.")
|
||||
|
||||
def navegar_menu_inicial(self, window) -> None:
|
||||
@@ -799,9 +674,35 @@ class DigitadorService:
|
||||
self.first_time = True
|
||||
self.log("Menú listo para radicar.")
|
||||
|
||||
def inicio_completo_app(self) -> Tuple[Any, Any]:
|
||||
"""Replica el inicio limpio del script: cerrar, abrir, conectar y navegar."""
|
||||
try:
|
||||
self._set_status("Conectando", "Inicio limpio de Integración Total", emit=False)
|
||||
self.log("Cerrando instancias previas de Salud Total...")
|
||||
os.system(f'taskkill /f /im "{PROCESS_NAME}"')
|
||||
time.sleep(3)
|
||||
if not os.path.exists(self.app_path):
|
||||
raise FileNotFoundError(f"No existe DIGITADOR_APP_PATH: {self.app_path}")
|
||||
self.log("Abriendo aplicación Salud Total...")
|
||||
os.startfile(self.app_path)
|
||||
self.log("Esperando carga inicial de la aplicación...")
|
||||
time.sleep(12)
|
||||
app = Application(backend="uia").connect(title_re=".*Integracion Total Fase 2.*")
|
||||
window = app.window(title_re=".*Integracion Total Fase 2.*")
|
||||
window.wait("visible enabled ready", timeout=30)
|
||||
window.wrapper_object().set_focus()
|
||||
self.navegar_menu_inicial(window)
|
||||
self.first_time = True
|
||||
self.log("Aplicación Salud Total lista para procesar.")
|
||||
return app, window
|
||||
except Exception as exc:
|
||||
self.log(f"ERROR iniciando aplicación Salud Total: {exc}")
|
||||
return None, None
|
||||
|
||||
def reinicio_completo_app(self) -> Tuple[Any, Any]:
|
||||
try:
|
||||
self.reinicios += 1
|
||||
self._set_status("Reinicio app", "Reinicio completo", emit=False)
|
||||
self.log("Reinicio completo preventivo de la aplicación...")
|
||||
self._emit("browser_reconnected", "Reinicio completo de Integración Total")
|
||||
|
||||
@@ -839,25 +740,7 @@ class DigitadorService:
|
||||
self.delimiter = delim
|
||||
self.log(f"Delimitador CSV detectado/usado: '{delim}'")
|
||||
|
||||
data: List[Dict[str, str]] = []
|
||||
with open(self.csv_path, newline="", encoding="utf-8-sig") as csvfile:
|
||||
reader = csv.DictReader(csvfile, delimiter=delim)
|
||||
headers = [h.strip() for h in reader.fieldnames] if reader.fieldnames else []
|
||||
reader.fieldnames = headers
|
||||
|
||||
if not REQUIRED_COLUMNS.issubset(headers):
|
||||
missing = REQUIRED_COLUMNS - set(headers)
|
||||
raise ValueError(
|
||||
"El CSV no tiene las columnas requeridas.\n"
|
||||
f"Faltan: {', '.join(sorted(missing))}\n"
|
||||
"Verifique si el delimitador es correcto."
|
||||
)
|
||||
|
||||
for row in reader:
|
||||
cleaned = {k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()}
|
||||
data.append(cleaned)
|
||||
|
||||
return data
|
||||
return load_csv_rows(self.csv_path, delim)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Loop principal
|
||||
@@ -868,11 +751,14 @@ class DigitadorService:
|
||||
window = None
|
||||
|
||||
try:
|
||||
validate_templates(self.template_dir)
|
||||
self.log("Conectando a la aplicación...")
|
||||
self._set_status("Conectando", "Validando Integración Total")
|
||||
self._emit("run_started", f"Iniciando digitación. CSV: {self.csv_path}")
|
||||
|
||||
app = Application(backend="uia").connect(title_re=".*Integracion Total Fase 2.*")
|
||||
window = app.window(title_re=".*Integracion Total Fase 2.*")
|
||||
app, window = self.inicio_completo_app()
|
||||
if window is None:
|
||||
raise RuntimeError("No fue posible iniciar o conectar con Salud Total")
|
||||
|
||||
self.omitted_records = []
|
||||
self.log_entries = []
|
||||
@@ -888,6 +774,8 @@ class DigitadorService:
|
||||
self.first_time = True
|
||||
self._cancelled = False
|
||||
self._fatal_error = None
|
||||
self.estado_actual = "Procesando"
|
||||
self.ultimo_evento = "Ejecución iniciada"
|
||||
|
||||
self.log(f"Iniciando nuevo proceso. Reportes en: {self.output_dir}")
|
||||
self.log(f"APP_PATH={self.app_path}")
|
||||
@@ -912,6 +800,7 @@ class DigitadorService:
|
||||
|
||||
self.inicio_registro = time.time()
|
||||
self.registro_actual = row.get("radicado", "N/A")
|
||||
self._set_status("Procesando", f"Registro {i + 1}/{len(data)}", emit=False)
|
||||
self._emit(
|
||||
"radicado_processing",
|
||||
f"Procesando registro {i + 1}/{len(data)}: {self.registro_actual}",
|
||||
@@ -1018,7 +907,7 @@ class DigitadorService:
|
||||
|
||||
tipos_a_probar: List[str] = []
|
||||
|
||||
if val_tipo_doc and val_tipo_doc in DOC_COORDS:
|
||||
if val_tipo_doc and val_tipo_doc in DOC_KEY_SEQUENCES:
|
||||
tipos_a_probar.append(val_tipo_doc)
|
||||
for t in candidatos_ordenados:
|
||||
if t != val_tipo_doc:
|
||||
@@ -1045,16 +934,9 @@ class DigitadorService:
|
||||
self.log(f"Probando tipo de documento: {tipo_doc}")
|
||||
|
||||
time.sleep(1)
|
||||
click_abs(window, 729, 279)
|
||||
|
||||
coords_doc = DOC_COORDS.get(tipo_doc, (0, 0))
|
||||
if coords_doc == (0, 0):
|
||||
self.log(f"Advertencia: Coordenadas para {tipo_doc} no configuradas. Saltando.")
|
||||
if not self.seleccionar_tipo_documento(window, tipo_doc):
|
||||
continue
|
||||
|
||||
time.sleep(0.5)
|
||||
click_abs(window, coords_doc[0], coords_doc[1])
|
||||
|
||||
time.sleep(0.2)
|
||||
click_abs(window, 1015, 280)
|
||||
|
||||
@@ -1244,6 +1126,7 @@ class DigitadorService:
|
||||
click_abs(window, 941, 305)
|
||||
|
||||
self.procesados_ok += 1
|
||||
self._set_status("OK", "Registro OK", emit=False)
|
||||
self._emit(
|
||||
"radicado_saved",
|
||||
f"Registro OK: {val_radicado}",
|
||||
@@ -1270,6 +1153,7 @@ class DigitadorService:
|
||||
self.log("TIMEOUT: No apareció éxito ni error después de registrar.")
|
||||
self.errores += 1
|
||||
self.timeouts += 1
|
||||
self._set_status("Timeout", "Sin respuesta", emit=False)
|
||||
self.guardar_resultado(
|
||||
val_radicado,
|
||||
"TIMEOUT",
|
||||
@@ -1300,6 +1184,7 @@ class DigitadorService:
|
||||
break
|
||||
|
||||
if not summary.cancelled:
|
||||
self._set_status("Finalizado", "Proceso finalizado", emit=False)
|
||||
self.log("--- PROCESO FINALIZADO ---")
|
||||
self._emit("run_finished", "Proceso finalizado")
|
||||
|
||||
@@ -1307,6 +1192,7 @@ class DigitadorService:
|
||||
|
||||
except Exception as e:
|
||||
self._fatal_error = str(e)
|
||||
self._set_status("Error", str(e), emit=False)
|
||||
self.log(f"ERROR: {str(e)}")
|
||||
self._emit("warning", f"Error fatal: {e}", metadata={"fatal": True})
|
||||
self.save_reports()
|
||||
@@ -1319,6 +1205,11 @@ class DigitadorService:
|
||||
summary.fallidos = self.omitidos + self.timeouts
|
||||
summary.cancelled = summary.cancelled or self._cancelled
|
||||
summary.fatal_error = self._fatal_error
|
||||
summary.reinicios = self.reinicios
|
||||
summary.elapsed_seconds = int(time.time() - self.inicio_proceso) if self.inicio_proceso else 0
|
||||
elapsed_minutes = max(summary.elapsed_seconds / 60, 0.01) if summary.elapsed_seconds else 0
|
||||
processed_total = summary.exitosos + summary.fallidos + summary.existentes
|
||||
summary.speed_per_minute = round(processed_total / elapsed_minutes, 2) if elapsed_minutes else 0.0
|
||||
summary.resultados = list(self.resultados)
|
||||
summary.output_dir = self.output_dir
|
||||
return summary
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Escritura determinista de los reportes producidos por una ejecución."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List
|
||||
|
||||
|
||||
REPORT_FILES = ("RESULTADOS.csv", "OMITIDOS.csv", "ejecucion_bot.log")
|
||||
RESULT_FIELDS = (
|
||||
"radicado", "estado", "tipo_error", "motivo", "detalle",
|
||||
"fecha_hora", "duracion_segundos", "accion_bot",
|
||||
)
|
||||
|
||||
|
||||
def write_reports(
|
||||
output_dir: str,
|
||||
*,
|
||||
log_entries: Iterable[str],
|
||||
omitted_records: List[Dict[str, Any]],
|
||||
results: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
root = Path(output_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
entries = list(log_entries)
|
||||
if entries:
|
||||
with (root / "ejecucion_bot.log").open("a", encoding="utf-8") as target:
|
||||
target.writelines(f"{entry}\n" for entry in entries)
|
||||
if omitted_records:
|
||||
fields: List[str] = []
|
||||
for record in omitted_records:
|
||||
fields.extend(key for key in record if key not in fields)
|
||||
with (root / "OMITIDOS.csv").open("w", newline="", encoding="utf-8-sig") as target:
|
||||
writer = csv.DictWriter(target, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(omitted_records)
|
||||
if results:
|
||||
with (root / "RESULTADOS.csv").open("w", newline="", encoding="utf-8-sig") as target:
|
||||
writer = csv.DictWriter(target, fieldnames=RESULT_FIELDS)
|
||||
writer.writeheader()
|
||||
writer.writerows(results)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Inventario y validación de plantillas OpenCV del Bot Digitador."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES
|
||||
|
||||
|
||||
def missing_templates(template_dir: str, required: Iterable[str] = REQUIRED_TEMPLATE_NAMES) -> List[str]:
|
||||
root = Path(template_dir)
|
||||
return [name for name in required if not (root / name).is_file()]
|
||||
|
||||
|
||||
def validate_templates(template_dir: str) -> None:
|
||||
missing = missing_templates(template_dir)
|
||||
if missing:
|
||||
formatted = ", ".join(missing)
|
||||
raise RuntimeError(
|
||||
f"Faltan {len(missing)} plantillas OpenCV en {template_dir}: {formatted}. "
|
||||
"Copie la carpeta template completa antes de conectar el agent."
|
||||
)
|
||||
|
||||
@@ -7,6 +7,8 @@ import threading
|
||||
import customtkinter as ctk
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH
|
||||
from bot_digitador.models.events import DigitadorEvent
|
||||
from bot_digitador.ui.progress_overlay import DigitadorProgressOverlay
|
||||
from bot_digitador.ui.settings_store import (
|
||||
DEFAULT_API_URL,
|
||||
load_gui_settings,
|
||||
@@ -40,6 +42,7 @@ class BotDashboard(ctk.CTk):
|
||||
|
||||
self._construir_ui()
|
||||
self._cargar_settings()
|
||||
self._overlay = DigitadorProgressOverlay(self)
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
@@ -299,6 +302,7 @@ class BotDashboard(ctk.CTk):
|
||||
token=token,
|
||||
app_path=app_path,
|
||||
log_func=self._log,
|
||||
event_func=self._on_agent_event,
|
||||
)
|
||||
self._connected = True
|
||||
self.btn_connect.configure(text="⏹ Desconectar", fg_color="#c0392b", hover_color="#a93226")
|
||||
@@ -363,6 +367,9 @@ class BotDashboard(ctk.CTk):
|
||||
|
||||
self.after(0, _escribir)
|
||||
|
||||
def _on_agent_event(self, event: DigitadorEvent) -> None:
|
||||
self.after(0, lambda current=event: self._overlay.apply_event(current))
|
||||
|
||||
def _on_close(self):
|
||||
if self._agent:
|
||||
self._agent.stop()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Visor local compacto del progreso del digitador."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import tkinter as tk
|
||||
from typing import Any
|
||||
|
||||
from bot_digitador.models.events import DigitadorEvent
|
||||
|
||||
|
||||
def format_duration(seconds: int | float) -> str:
|
||||
value = max(0, int(seconds))
|
||||
hours, remainder = divmod(value, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
class DigitadorProgressOverlay:
|
||||
"""Panel flotante 1:1 con el visor del bot monolítico."""
|
||||
|
||||
def __init__(self, parent: tk.Misc):
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("BOT ST")
|
||||
self.window.geometry("210x235+15+555")
|
||||
self.window.overrideredirect(True)
|
||||
self.window.attributes("-topmost", True)
|
||||
self.window.attributes("-alpha", 0.88)
|
||||
self.window.configure(bg="#101010")
|
||||
|
||||
self._vars: dict[str, tk.StringVar] = {}
|
||||
self._metadata: dict[str, Any] = {}
|
||||
self._started_at: float | None = None
|
||||
self._running = False
|
||||
|
||||
self._add_label("titulo", "BOT SALUD TOTAL", ("Consolas", 9, "bold"), "#00ff88")
|
||||
self._add_label("estado", "ESTADO: Listo", ("Consolas", 8, "bold"), "#ffffff")
|
||||
self._add_label("progreso", "0/0 0%", ("Consolas", 8), "#ffffff")
|
||||
self._add_label("ok", "OK: 0", ("Consolas", 8), "#00ff88")
|
||||
self._add_label("err", "ERR: 0 | EX: 0", ("Consolas", 8), "#ff6666")
|
||||
self._add_label("rei", "REI: 0", ("Consolas", 8), "#66ccff")
|
||||
self._add_label("rad", "RAD: -", ("Consolas", 8), "#ffffff")
|
||||
self._add_label("vel", "VEL: 0 rad/min", ("Consolas", 8), "#ffff99")
|
||||
self._add_label("tiempo", "TIME: 00:00:00", ("Consolas", 8), "#ffffff")
|
||||
self._add_label("ultimo", "ULT: -", ("Consolas", 8), "#cccccc")
|
||||
self.window.after(1000, self._tick)
|
||||
|
||||
def _add_label(self, key: str, text: str, font: tuple, color: str) -> None:
|
||||
variable = tk.StringVar(master=self.window, value=text)
|
||||
tk.Label(
|
||||
self.window,
|
||||
textvariable=variable,
|
||||
bg="#101010",
|
||||
fg=color,
|
||||
font=font,
|
||||
anchor="w",
|
||||
justify="left",
|
||||
).pack(fill="x", padx=8, pady=1)
|
||||
self._vars[key] = variable
|
||||
|
||||
def apply_event(self, event: DigitadorEvent) -> None:
|
||||
metadata = dict(event.metadata or {})
|
||||
if event.event_type == "run_started":
|
||||
self._metadata = {}
|
||||
self._started_at = time.monotonic()
|
||||
self._running = True
|
||||
|
||||
self._metadata.update(metadata)
|
||||
if event.total is not None:
|
||||
self._metadata["total"] = event.total
|
||||
if event.radicado:
|
||||
self._metadata["radicado_actual"] = event.radicado
|
||||
self._metadata["ultimo"] = metadata.get("ultimo") or event.message
|
||||
|
||||
elapsed = float(metadata.get("elapsed_seconds") or 0)
|
||||
if self._running and elapsed and self._started_at is not None:
|
||||
self._started_at = time.monotonic() - elapsed
|
||||
if event.event_type == "run_finished":
|
||||
self._running = False
|
||||
|
||||
self._refresh()
|
||||
|
||||
def set_connection_state(self, state: str, detail: str = "-") -> None:
|
||||
self._metadata.update({"estado": state, "ultimo": detail})
|
||||
self._refresh()
|
||||
|
||||
def _elapsed(self) -> int:
|
||||
if self._running and self._started_at is not None:
|
||||
return int(time.monotonic() - self._started_at)
|
||||
return int(self._metadata.get("elapsed_seconds") or 0)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
metadata = self._metadata
|
||||
processed = int(metadata.get("processed") or 0)
|
||||
total = int(metadata.get("total") or 0)
|
||||
percentage = int(processed / total * 100) if total else 0
|
||||
elapsed = self._elapsed()
|
||||
speed = float(metadata.get("speed_per_minute") or 0)
|
||||
if self._running and elapsed:
|
||||
speed = processed / max(elapsed / 60, 0.01)
|
||||
|
||||
self._vars["estado"].set(f"ESTADO: {str(metadata.get('estado') or 'Listo')[:18]}")
|
||||
self._vars["progreso"].set(f"{processed}/{total} {percentage}%")
|
||||
self._vars["ok"].set(f"OK: {int(metadata.get('exitosos') or 0)}")
|
||||
self._vars["err"].set(
|
||||
f"ERR: {int(metadata.get('errores') or 0)} | EX: {int(metadata.get('existentes') or 0)}"
|
||||
)
|
||||
self._vars["rei"].set(f"REI: {int(metadata.get('reinicios') or 0)}")
|
||||
self._vars["rad"].set(f"RAD: {str(metadata.get('radicado_actual') or '-')[:18]}")
|
||||
self._vars["vel"].set(f"VEL: {speed:.1f} rad/min")
|
||||
self._vars["tiempo"].set(f"TIME: {format_duration(elapsed)}")
|
||||
self._vars["ultimo"].set(f"ULT: {str(metadata.get('ultimo') or '-')[:18]}")
|
||||
|
||||
def _tick(self) -> None:
|
||||
try:
|
||||
if self.window.winfo_exists():
|
||||
self._refresh()
|
||||
self.window.after(1000, self._tick)
|
||||
except tk.TclError:
|
||||
return
|
||||
@@ -11,15 +11,14 @@ import httpx
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH
|
||||
from bot_digitador.core.digitador_service import DigitadorService
|
||||
from bot_digitador.core.reports import REPORT_FILES
|
||||
from bot_digitador.core.vision import validate_templates
|
||||
from bot_digitador.worker.minio_upload import MinioSettings, WorkerMinioUploader
|
||||
|
||||
JOB_COMPLETED = "completed"
|
||||
JOB_FAILED = "failed"
|
||||
JOB_CANCELLED = "cancelled"
|
||||
|
||||
REPORT_FILES = ("RESULTADOS.csv", "OMITIDOS.csv", "ejecucion_bot.log")
|
||||
|
||||
|
||||
class RemoteApiClient:
|
||||
"""Cliente HTTP hacia la API bot-digitador (vía gateway). Sin puertos locales."""
|
||||
|
||||
@@ -102,6 +101,8 @@ class WorkerAgent:
|
||||
date_format: str = "dd/mm/yyyy",
|
||||
delimiter: str = "|",
|
||||
log_func=None,
|
||||
template_dir: Optional[Path] = None,
|
||||
event_func=None,
|
||||
):
|
||||
self.api = RemoteApiClient(api_url, token)
|
||||
self.worker_id = worker_id
|
||||
@@ -111,14 +112,17 @@ class WorkerAgent:
|
||||
self.date_format = date_format
|
||||
self.delimiter = delimiter
|
||||
self.minio = WorkerMinioUploader(MinioSettings.from_env())
|
||||
self.template_dir = template_dir or Path(__file__).resolve().parents[2] / "template"
|
||||
self._running = True
|
||||
self._uploaded_keys: List[str] = []
|
||||
self._log = log_func or print
|
||||
self._event_func = event_func
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def run_forever(self) -> None:
|
||||
validate_templates(str(self.template_dir))
|
||||
self.api.register(self.worker_id)
|
||||
self._log(f"[worker {self.worker_id}] registrado — idle (esperando jobs de digitación)")
|
||||
try:
|
||||
@@ -165,6 +169,11 @@ class WorkerAgent:
|
||||
|
||||
def on_event(event) -> None:
|
||||
self._log(event.display_message())
|
||||
if self._event_func:
|
||||
try:
|
||||
self._event_func(event)
|
||||
except Exception as e:
|
||||
self._log(f"[worker] no se pudo actualizar el visor local: {e}")
|
||||
payload = {
|
||||
"event_type": event.event_type,
|
||||
"message": event.message,
|
||||
@@ -190,6 +199,7 @@ class WorkerAgent:
|
||||
app_path=self.app_path,
|
||||
event_callback=on_event,
|
||||
should_cancel=should_cancel,
|
||||
template_dir=str(self.template_dir),
|
||||
)
|
||||
summary = service.run()
|
||||
|
||||
@@ -248,6 +258,7 @@ class WorkerAgent:
|
||||
|
||||
result = {
|
||||
"total": summary.total,
|
||||
"processed": summary.exitosos + summary.fallidos + summary.existentes,
|
||||
"exitosos": summary.exitosos,
|
||||
"fallidos": summary.fallidos,
|
||||
"existentes": summary.existentes,
|
||||
@@ -256,6 +267,9 @@ class WorkerAgent:
|
||||
"cancelled": summary.cancelled,
|
||||
"reports_prefix": reports_prefix,
|
||||
"uploaded_count": len(self._uploaded_keys),
|
||||
"reinicios": summary.reinicios,
|
||||
"elapsed_seconds": summary.elapsed_seconds,
|
||||
"speed_per_minute": summary.speed_per_minute,
|
||||
}
|
||||
|
||||
if summary.cancelled:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Construye un ZIP reproducible y limpio del Bot Digitador."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES # noqa: E402
|
||||
|
||||
|
||||
DEFAULT_OUTPUT = ROOT.parent / "bot_digitador.zip"
|
||||
TOP_LEVEL_FILES = (".env.example", "README.md", "requirements.txt", "run_gui.py", "main.py")
|
||||
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache", ".venv", "data", "reportes_bot", "out"}
|
||||
EXCLUDED_SUFFIXES = {".pyc", ".pyo", ".log"}
|
||||
|
||||
|
||||
def package_files(root: Path) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for name in TOP_LEVEL_FILES:
|
||||
path = root / name
|
||||
if path.is_file():
|
||||
files.append(path)
|
||||
for folder in (root / "bot_digitador", root / "template"):
|
||||
if not folder.exists():
|
||||
continue
|
||||
for path in folder.rglob("*"):
|
||||
relative = path.relative_to(root)
|
||||
if not path.is_file() or any(part in EXCLUDED_PARTS for part in relative.parts):
|
||||
continue
|
||||
if path.suffix.lower() in EXCLUDED_SUFFIXES or path.name == ".env":
|
||||
continue
|
||||
files.append(path)
|
||||
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
|
||||
|
||||
|
||||
def validate_assets(root: Path) -> None:
|
||||
missing = [name for name in REQUIRED_TEMPLATE_NAMES if not (root / "template" / name).is_file()]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"No se puede crear un paquete ejecutable. Faltan plantillas: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def build_package(root: Path = ROOT, output: Path = DEFAULT_OUTPUT, *, require_templates: bool = True) -> Path:
|
||||
if require_templates:
|
||||
validate_assets(root)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with ZipFile(output, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for source in package_files(root):
|
||||
relative = source.relative_to(root).as_posix()
|
||||
info = ZipInfo(f"bot_digitador/{relative}", date_time=(2020, 1, 1, 0, 0, 0))
|
||||
info.compress_type = ZIP_DEFLATED
|
||||
info.external_attr = 0o644 << 16
|
||||
archive.writestr(info, source.read_bytes())
|
||||
return output
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
parser.add_argument("--allow-missing-templates", action="store_true")
|
||||
args = parser.parse_args()
|
||||
target = build_package(output=args.output.resolve(), require_templates=not args.allow_missing_templates)
|
||||
print(f"Paquete: {target}")
|
||||
print(f"SHA256: {sha256(target)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Construye y publica el Bot Digitador en el prefijo latest de MinIO."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
from package_bot import DEFAULT_OUTPUT, build_package, sha256
|
||||
|
||||
|
||||
DEFAULT_ENDPOINT = "https://s3.builker.xyz"
|
||||
DEFAULT_BUCKET = "apps-versions"
|
||||
DEFAULT_KEY = "BotDigitadorPYSCuentasMedicas/latest/bot_digitador.zip"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
parser.add_argument("--build-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
artifact = build_package(output=args.output.resolve(), require_templates=True)
|
||||
print(f"Paquete validado: {artifact} ({sha256(artifact)})")
|
||||
if args.build_only:
|
||||
return 0
|
||||
|
||||
access = os.getenv("MINIO_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
|
||||
secret = os.getenv("MINIO_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
if not access or not secret:
|
||||
print("Faltan MINIO_ACCESS_KEY/MINIO_SECRET_KEY para publicar.", file=sys.stderr)
|
||||
return 2
|
||||
endpoint = os.getenv("MINIO_ENDPOINT", DEFAULT_ENDPOINT).rstrip("/")
|
||||
bucket = os.getenv("MINIO_APPS_BUCKET", DEFAULT_BUCKET)
|
||||
key = os.getenv("DIGITADOR_PACKAGE_KEY", DEFAULT_KEY).strip("/")
|
||||
client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=access,
|
||||
aws_secret_access_key=secret,
|
||||
region_name=os.getenv("MINIO_REGION", "us-east-1"),
|
||||
config=Config(s3={"addressing_style": "path"}),
|
||||
)
|
||||
client.upload_file(
|
||||
str(artifact),
|
||||
bucket,
|
||||
key,
|
||||
ExtraArgs={"ContentType": "application/zip"},
|
||||
)
|
||||
print(f"Publicado: {endpoint}/{bucket}/{key}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -14,3 +14,4 @@ Copia aquí los PNG necesarios desde la carpeta `template/` del script original
|
||||
- `radicado_existente.png`
|
||||
|
||||
Sin estos archivos el bot no puede validar pantallas de Integración Total.
|
||||
El agent y el empaquetador validan el inventario completo antes de ejecutar o publicar.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bot_digitador.core.application import select_document_type
|
||||
|
||||
|
||||
class FakeWrapper:
|
||||
def __init__(self):
|
||||
self.keys: list[str] = []
|
||||
|
||||
def type_keys(self, value: str, **_kwargs) -> None:
|
||||
self.keys.append(value)
|
||||
|
||||
|
||||
def test_select_document_type_uses_home_and_exact_c_sequence():
|
||||
wrapper = FakeWrapper()
|
||||
window = SimpleNamespace(wrapper_object=lambda: wrapper)
|
||||
clicks: list[tuple[int, int]] = []
|
||||
logs: list[str] = []
|
||||
|
||||
ok = select_document_type(
|
||||
window,
|
||||
"CC",
|
||||
log=logs.append,
|
||||
sleep=lambda _seconds: None,
|
||||
click=lambda _window, x, y: clicks.append((x, y)),
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert clicks == [(729, 279)]
|
||||
assert wrapper.keys == ["{HOME}", "c", "c", "c", "{ENTER}"]
|
||||
|
||||
|
||||
def test_select_document_type_rejects_unknown_type():
|
||||
assert select_document_type(SimpleNamespace(), "XX", log=lambda _message: None) is False
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH, resolve_app_path
|
||||
|
||||
|
||||
def test_default_app_path_uses_current_user_home():
|
||||
assert Path(DEFAULT_APP_PATH) == Path.home() / "Documents" / "IT SaludTotal.lnk"
|
||||
|
||||
|
||||
def test_app_path_expands_worker_user_profile(monkeypatch):
|
||||
monkeypatch.setenv("USERPROFILE", r"C:\Users\worker-02")
|
||||
|
||||
resolved = resolve_app_path(r"%USERPROFILE%\Documents\IT SaludTotal.lnk")
|
||||
|
||||
assert resolved == r"C:\Users\worker-02\Documents\IT SaludTotal.lnk"
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bot_digitador.core.csv_rules import (
|
||||
convert_date,
|
||||
detect_csv_delimiter,
|
||||
format_money_for_input,
|
||||
parse_money,
|
||||
valid_document_types,
|
||||
)
|
||||
|
||||
|
||||
HEADERS = "radicado|documento|ingreso|total_cargos|autorizacion|tipo_documento|tipo_servicio|copago|cuota_moderadora\n"
|
||||
|
||||
|
||||
def test_detects_pipe_csv(tmp_path):
|
||||
source = tmp_path / "input.csv"
|
||||
source.write_text(HEADERS + "R1|123|2026-08-30|100|A|CC|ambulatorio|0|0\n", encoding="utf-8")
|
||||
assert detect_csv_delimiter(str(source), preferred=",") == "|"
|
||||
|
||||
|
||||
def test_dates_and_money_match_original_rules():
|
||||
assert convert_date("30/08/2026", "dd/mm/yyyy") == ("08302026", True)
|
||||
assert convert_date("2026-08-30", "dd/mm/yyyy") == ("08302026", True)
|
||||
assert parse_money("$ 12.500") == 12500.0
|
||||
assert parse_money("12,50") == 12.5
|
||||
assert format_money_for_input("12,50") == "12,5"
|
||||
|
||||
|
||||
def test_document_candidates_preserve_strategy_rules():
|
||||
candidates = valid_document_types("1234567890")
|
||||
assert {"CC", "TI", "RC", "CN", "AS", "ME", "PS"}.issubset(candidates)
|
||||
assert valid_document_types("") == []
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "package_bot.py"
|
||||
SPEC = importlib.util.spec_from_file_location("digitador_package_bot", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
package_bot = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(package_bot)
|
||||
|
||||
|
||||
def test_package_is_clean_and_contains_required_templates(tmp_path):
|
||||
root = tmp_path / "source"
|
||||
(root / "bot_digitador" / "core").mkdir(parents=True)
|
||||
(root / "template").mkdir()
|
||||
for name in ("README.md", "requirements.txt", "run_gui.py", "main.py", ".env.example"):
|
||||
(root / name).write_text(name, encoding="utf-8")
|
||||
(root / "bot_digitador" / "core" / "service.py").write_text("pass", encoding="utf-8")
|
||||
(root / "bot_digitador" / "core" / "cached.pyc").write_bytes(b"cache")
|
||||
(root / "data").mkdir()
|
||||
(root / "data" / "gui_settings.json").write_text("secret", encoding="utf-8")
|
||||
for name in REQUIRED_TEMPLATE_NAMES:
|
||||
(root / "template" / name).write_bytes(b"png")
|
||||
|
||||
target = package_bot.build_package(root=root, output=tmp_path / "bot.zip")
|
||||
with ZipFile(target) as archive:
|
||||
names = archive.namelist()
|
||||
assert "bot_digitador/template/radicado_existente.png" in names
|
||||
assert not any("__pycache__" in name or name.endswith(".pyc") or "/data/" in name for name in names)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from bot_digitador.ui.progress_overlay import format_duration
|
||||
|
||||
|
||||
def test_format_duration_for_local_viewer():
|
||||
assert format_duration(0) == "00:00:00"
|
||||
assert format_duration(65) == "00:01:05"
|
||||
assert format_duration(3661) == "01:01:01"
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
|
||||
import pytest
|
||||
|
||||
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES
|
||||
from bot_digitador.core.reports import write_reports
|
||||
from bot_digitador.core.vision import missing_templates, validate_templates
|
||||
|
||||
|
||||
def test_template_inventory_reports_every_missing_asset(tmp_path):
|
||||
assert missing_templates(str(tmp_path)) == list(REQUIRED_TEMPLATE_NAMES)
|
||||
with pytest.raises(RuntimeError, match="Faltan 9 plantillas"):
|
||||
validate_templates(str(tmp_path))
|
||||
for name in REQUIRED_TEMPLATE_NAMES:
|
||||
(tmp_path / name).write_bytes(b"png")
|
||||
validate_templates(str(tmp_path))
|
||||
|
||||
|
||||
def test_report_writer_creates_all_outputs(tmp_path):
|
||||
omitted = [{"radicado": "R2", "estado": "OMITIDO", "tipo_error": "ERROR"}]
|
||||
results = [{
|
||||
"radicado": "R1", "estado": "OK", "tipo_error": "", "motivo": "Cuenta registrada",
|
||||
"detalle": "BIEN", "fecha_hora": "2026-08-30 12:00:00", "duracion_segundos": 10,
|
||||
"accion_bot": "Continuó",
|
||||
}]
|
||||
write_reports(str(tmp_path), log_entries=["inicio"], omitted_records=omitted, results=results)
|
||||
assert (tmp_path / "ejecucion_bot.log").read_text(encoding="utf-8") == "inicio\n"
|
||||
with (tmp_path / "RESULTADOS.csv").open(encoding="utf-8-sig") as source:
|
||||
assert list(csv.DictReader(source))[0]["radicado"] == "R1"
|
||||
assert (tmp_path / "OMITIDOS.csv").is_file()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bot_digitador.core.digitador_service import DigitadorService
|
||||
|
||||
|
||||
def test_events_include_remote_overlay_metrics(tmp_path):
|
||||
events = []
|
||||
service = DigitadorService(
|
||||
csv_path=str(tmp_path / "input.csv"),
|
||||
output_dir=str(tmp_path / "out"),
|
||||
event_callback=events.append,
|
||||
)
|
||||
service.inicio_proceso = None
|
||||
service.total_registros = 10
|
||||
service.procesados_ok = 2
|
||||
service.errores = 1
|
||||
service.existentes = 1
|
||||
service.reinicios = 3
|
||||
service.registro_actual = "RAD-4"
|
||||
service.estado_actual = "Procesando"
|
||||
service.ultimo_evento = "Registro 4/10"
|
||||
|
||||
service._emit("radicado_processing", "Procesando RAD-4")
|
||||
|
||||
metadata = events[0].metadata
|
||||
assert metadata["estado"] == "Procesando"
|
||||
assert metadata["processed"] == 4
|
||||
assert metadata["reinicios"] == 3
|
||||
assert metadata["radicado_actual"] == "RAD-4"
|
||||
@@ -560,10 +560,29 @@ def post_event(
|
||||
metadata=body.metadata,
|
||||
timestamp=body.timestamp,
|
||||
)
|
||||
if body.index is not None and body.total is not None:
|
||||
overlay_keys = {
|
||||
"estado",
|
||||
"ultimo",
|
||||
"processed",
|
||||
"exitosos",
|
||||
"errores",
|
||||
"omitidos",
|
||||
"existentes",
|
||||
"reinicios",
|
||||
"radicado_actual",
|
||||
"elapsed_seconds",
|
||||
"speed_per_minute",
|
||||
}
|
||||
has_overlay = any(key in body.metadata for key in overlay_keys)
|
||||
if (body.index is not None and body.total is not None) or has_overlay:
|
||||
result = dict(job.result or {})
|
||||
result["processed"] = body.index
|
||||
result["total"] = body.total
|
||||
if body.index is not None and body.total is not None:
|
||||
result["total"] = body.total
|
||||
for key in overlay_keys:
|
||||
if key in body.metadata:
|
||||
result[key] = body.metadata[key]
|
||||
if "processed" not in body.metadata and body.index is not None:
|
||||
result["processed"] = body.index
|
||||
if body.nit:
|
||||
result["nit_actual"] = body.nit
|
||||
store.patch_job(digitador_job_id, result=result)
|
||||
|
||||
@@ -561,6 +561,9 @@ class JobStore:
|
||||
timeouts = 0
|
||||
existentes = 0
|
||||
uploaded = 0
|
||||
reinicios = 0
|
||||
elapsed_seconds = 0
|
||||
speed_per_minute = 0.0
|
||||
for c in children:
|
||||
r = dict(c.result or {})
|
||||
total += int(r.get("total") or 0)
|
||||
@@ -572,6 +575,9 @@ class JobStore:
|
||||
timeouts += int(r.get("timeouts") or r.get("timeout") or 0)
|
||||
existentes += int(r.get("existentes") or 0)
|
||||
uploaded += int(r.get("uploaded_count") or 0)
|
||||
reinicios += int(r.get("reinicios") or 0)
|
||||
elapsed_seconds = max(elapsed_seconds, int(r.get("elapsed_seconds") or 0))
|
||||
speed_per_minute += float(r.get("speed_per_minute") or 0)
|
||||
|
||||
processed = 0
|
||||
for c in children:
|
||||
@@ -605,6 +611,9 @@ class JobStore:
|
||||
"existentes": existentes,
|
||||
"processed": processed,
|
||||
"uploaded_count": uploaded,
|
||||
"reinicios": reinicios,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"speed_per_minute": round(speed_per_minute, 2),
|
||||
"reports_prefix": parent.reports_prefix,
|
||||
"shards": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
|
||||
from api import main
|
||||
|
||||
|
||||
def test_post_event_persists_live_overlay_in_job_result(monkeypatch):
|
||||
patched: list[dict] = []
|
||||
job = SimpleNamespace(result={"total": 10})
|
||||
event = SimpleNamespace(to_dict=lambda: {"id": 1})
|
||||
fake_store = SimpleNamespace(
|
||||
get_job=lambda _job_id: job,
|
||||
add_event=lambda *_args, **_kwargs: event,
|
||||
patch_job=lambda _job_id, **kwargs: patched.append(kwargs) or job,
|
||||
)
|
||||
monkeypatch.setattr(main, "store", fake_store)
|
||||
|
||||
response = main.post_event(
|
||||
"job-1",
|
||||
main.EventBody(
|
||||
event_type="radicado_processing",
|
||||
message="Procesando",
|
||||
index=4,
|
||||
total=10,
|
||||
metadata={
|
||||
"estado": "Procesando",
|
||||
"processed": 3,
|
||||
"reinicios": 1,
|
||||
"radicado_actual": "RAD-4",
|
||||
"elapsed_seconds": 90,
|
||||
"speed_per_minute": 2.0,
|
||||
},
|
||||
),
|
||||
_="token",
|
||||
)
|
||||
|
||||
assert response == {"id": 1}
|
||||
result = patched[0]["result"]
|
||||
assert result["processed"] == 3
|
||||
assert result["total"] == 10
|
||||
assert result["radicado_actual"] == "RAD-4"
|
||||
assert result["reinicios"] == 1
|
||||
@@ -46,7 +46,7 @@ def test_distributed_parent_uses_agent_counters_and_live_progress(store):
|
||||
result={"total": 4, "processed": 2},
|
||||
status=JobStatus.RUNNING.value,
|
||||
)
|
||||
second = job_store.create_job(
|
||||
_second = job_store.create_job(
|
||||
"PC-02",
|
||||
"uploads/second/input.csv",
|
||||
parent_digitador_job_id=parent.id,
|
||||
@@ -72,6 +72,9 @@ def test_distributed_parent_uses_agent_counters_and_live_progress(store):
|
||||
"timeouts": 1,
|
||||
"existentes": 1,
|
||||
"uploaded_count": 3,
|
||||
"reinicios": 2,
|
||||
"elapsed_seconds": 90,
|
||||
"speed_per_minute": 1.5,
|
||||
},
|
||||
)
|
||||
live = job_store.refresh_parent_from_shards(parent.id)
|
||||
@@ -82,3 +85,6 @@ def test_distributed_parent_uses_agent_counters_and_live_progress(store):
|
||||
assert live.result["existentes"] == 1
|
||||
assert live.result["timeouts"] == 1
|
||||
assert live.result["uploaded_count"] == 3
|
||||
assert live.result["reinicios"] == 2
|
||||
assert live.result["elapsed_seconds"] == 90
|
||||
assert live.result["speed_per_minute"] == 1.5
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ipsextractor-desktop",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "Aplicación de escritorio Flujo IPS para automatizar procesos de cuentas médicas.",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
@@ -48,4 +48,35 @@ describe("App", () => {
|
||||
expect(screen.getByRole("heading", { name: /limpieza.*cop/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("2024")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the digitador package download inside an open job", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
const body = url.endsWith("/jobs")
|
||||
? [{
|
||||
id: "job-1",
|
||||
status: "pending",
|
||||
title: "Trabajo prueba",
|
||||
metadata: {},
|
||||
created_at: "2026-08-30T12:00:00Z",
|
||||
updated_at: "2026-08-30T12:00:00Z",
|
||||
}]
|
||||
: [];
|
||||
return { ok: true, text: async () => JSON.stringify(body) } as Response;
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByText("Trabajo prueba"));
|
||||
const digitadorMenu = screen
|
||||
.getAllByRole("button", { name: /bot digitador/i })
|
||||
.find((button) => button.hasAttribute("aria-expanded"));
|
||||
expect(digitadorMenu).toBeDefined();
|
||||
await user.click(digitadorMenu!);
|
||||
|
||||
expect(screen.getByRole("button", { name: /descargar bot/i })).toHaveAttribute(
|
||||
"title",
|
||||
"Descargar el paquete del bot digitador",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Download,
|
||||
Hash,
|
||||
Keyboard,
|
||||
Coins,
|
||||
Link2,
|
||||
Package,
|
||||
Settings,
|
||||
@@ -38,8 +39,10 @@ import "./modules/poblacion_apta/PoblacionAptaModule";
|
||||
import "./modules/downloads/DownloadsModule";
|
||||
import "./modules/digitador/DigitadorModule";
|
||||
|
||||
const BOT_PACKAGE_URL =
|
||||
const DOWNLOAD_BOT_PACKAGE_URL =
|
||||
"http://s3.builker.xyz/apps-versions/BotDescargaPYSCuentasMedicas/latest/bot_descarga.zip";
|
||||
const DIGITADOR_BOT_PACKAGE_URL =
|
||||
"http://s3.builker.xyz/apps-versions/BotDigitadorPYSCuentasMedicas/latest/bot_digitador.zip";
|
||||
|
||||
type Status = "idle" | "ok" | "error" | "busy";
|
||||
type View = "home" | "job" | "settings";
|
||||
@@ -100,8 +103,11 @@ function App() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [releaseDate, setReleaseDate] = useState(APP_RELEASE_DATE);
|
||||
const [downloadsMenuOpen, setDownloadsMenuOpen] = useState(true);
|
||||
const [digitadorMenuOpen, setDigitadorMenuOpen] = useState(false);
|
||||
const [botDownloadBusy, setBotDownloadBusy] = useState(false);
|
||||
const [botDownloadError, setBotDownloadError] = useState<string | null>(null);
|
||||
const [digitadorDownloadBusy, setDigitadorDownloadBusy] = useState(false);
|
||||
const [digitadorDownloadError, setDigitadorDownloadError] = useState<string | null>(null);
|
||||
const update = useAutoUpdateCheck();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,6 +214,7 @@ function App() {
|
||||
setActiveJob(job);
|
||||
setJobSection("downloads");
|
||||
setDownloadsMenuOpen(true);
|
||||
setDigitadorMenuOpen(false);
|
||||
setJobSectionContext({});
|
||||
setView("job");
|
||||
}
|
||||
@@ -216,6 +223,7 @@ function App() {
|
||||
setJobSection(section);
|
||||
setJobSectionContext(context);
|
||||
setDownloadsMenuOpen(section === "downloads");
|
||||
setDigitadorMenuOpen(section === "digitador");
|
||||
}
|
||||
|
||||
async function downloadBotPackage() {
|
||||
@@ -224,11 +232,11 @@ function App() {
|
||||
try {
|
||||
const api = window.desktopApi;
|
||||
if (typeof api?.downloadUrl === "function") {
|
||||
await api.downloadUrl(BOT_PACKAGE_URL);
|
||||
await api.downloadUrl(DOWNLOAD_BOT_PACKAGE_URL);
|
||||
return;
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = BOT_PACKAGE_URL;
|
||||
a.href = DOWNLOAD_BOT_PACKAGE_URL;
|
||||
a.download = "bot_descarga.zip";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.target = "_blank";
|
||||
@@ -242,6 +250,30 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDigitadorPackage() {
|
||||
setDigitadorDownloadBusy(true);
|
||||
setDigitadorDownloadError(null);
|
||||
try {
|
||||
const api = window.desktopApi;
|
||||
if (typeof api?.downloadUrl === "function") {
|
||||
await api.downloadUrl(DIGITADOR_BOT_PACKAGE_URL);
|
||||
return;
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = DIGITADOR_BOT_PACKAGE_URL;
|
||||
a.download = "bot_digitador.zip";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.target = "_blank";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch (err) {
|
||||
setDigitadorDownloadError(err instanceof Error ? err.message : "No se pudo iniciar la descarga del bot digitador.");
|
||||
} finally {
|
||||
setDigitadorDownloadBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings(section: SettingsSection = "connection") {
|
||||
setSettingsReturnView(view === "job" ? "job" : "home");
|
||||
setSettingsSection(section);
|
||||
@@ -405,13 +437,48 @@ function App() {
|
||||
<Users size={17} />
|
||||
<span>Población apta</span>
|
||||
</button>
|
||||
<div className="side-nav-group">
|
||||
<button
|
||||
type="button"
|
||||
className={jobSection === "digitador" ? "active" : ""}
|
||||
aria-expanded={digitadorMenuOpen}
|
||||
onClick={() => {
|
||||
if (jobSection === "digitador") {
|
||||
setDigitadorMenuOpen((open) => !open);
|
||||
} else {
|
||||
handleActivateSection("digitador");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Keyboard size={17} />
|
||||
<span>Bot digitador</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`side-nav-chevron${digitadorMenuOpen ? " side-nav-chevron--open" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{digitadorMenuOpen && (
|
||||
<div className="side-nav-sub" role="group" aria-label="Opciones del bot digitador">
|
||||
<button
|
||||
type="button"
|
||||
disabled={digitadorDownloadBusy}
|
||||
title={digitadorDownloadError || "Descargar el paquete del bot digitador"}
|
||||
onClick={() => void downloadDigitadorPackage()}
|
||||
>
|
||||
<Package size={15} />
|
||||
<span>{digitadorDownloadBusy ? "Descargando…" : "Descargar bot"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={jobSection === "digitador" ? "active" : ""}
|
||||
onClick={() => handleActivateSection("digitador")}
|
||||
className={jobSection === "costs" ? "active" : ""}
|
||||
onClick={() => handleActivateSection("costs")}
|
||||
>
|
||||
<Keyboard size={17} />
|
||||
<span>Bot digitador</span>
|
||||
<Coins size={17} />
|
||||
<span>Costos</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DigitadorLiveMetrics } from "./DigitadorLiveMetrics";
|
||||
|
||||
describe("DigitadorLiveMetrics", () => {
|
||||
it("renders the remote equivalent of the local overlay", () => {
|
||||
render(
|
||||
<DigitadorLiveMetrics
|
||||
events={[{
|
||||
id: 1,
|
||||
job_id: "job-1",
|
||||
event_type: "radicado_processing",
|
||||
message: "Procesando",
|
||||
timestamp: "2026-08-30T12:00:00Z",
|
||||
metadata: {
|
||||
estado: "Procesando",
|
||||
radicado_actual: "RAD-123",
|
||||
reinicios: 2,
|
||||
speed_per_minute: 3.5,
|
||||
elapsed_seconds: 3661,
|
||||
ultimo: "Registro 3/10",
|
||||
},
|
||||
}]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("RAD-123")).toBeInTheDocument();
|
||||
expect(screen.getByText("3.5 rad/min")).toBeInTheDocument();
|
||||
expect(screen.getByText("01:01:01")).toBeInTheDocument();
|
||||
expect(screen.getByText("Registro 3/10")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DigitadorJobEvent } from "../../../shared/types";
|
||||
|
||||
type Props = { events: DigitadorJobEvent[] };
|
||||
|
||||
function value(metadata: Record<string, unknown>, key: string, fallback: string | number = "—") {
|
||||
const raw = metadata[key];
|
||||
return raw === undefined || raw === null || raw === "" ? String(fallback) : String(raw);
|
||||
}
|
||||
|
||||
function duration(secondsValue: unknown): string {
|
||||
const total = Math.max(0, Number(secondsValue) || 0);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = Math.floor(total % 60);
|
||||
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
export function DigitadorLiveMetrics({ events }: Props) {
|
||||
const latest = [...events].reverse().find((event) => {
|
||||
const metadata = event.metadata ?? {};
|
||||
return "estado" in metadata || "radicado_actual" in metadata;
|
||||
});
|
||||
if (!latest) return null;
|
||||
const metadata = latest.metadata ?? {};
|
||||
|
||||
return (
|
||||
<div className="digitador-live-metrics" aria-label="Estado en vivo del bot digitador">
|
||||
<div><span>Estado</span><strong>{value(metadata, "estado", latest.event_type)}</strong></div>
|
||||
<div><span>Radicado</span><strong title={value(metadata, "radicado_actual")}>{value(metadata, "radicado_actual")}</strong></div>
|
||||
<div><span>Reinicios</span><strong>{value(metadata, "reinicios", 0)}</strong></div>
|
||||
<div><span>Velocidad</span><strong>{value(metadata, "speed_per_minute", 0)} rad/min</strong></div>
|
||||
<div><span>Tiempo</span><strong>{duration(metadata.elapsed_seconds)}</strong></div>
|
||||
<div><span>Último evento</span><strong title={value(metadata, "ultimo", latest.message)}>{value(metadata, "ultimo", latest.message)}</strong></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "../../../shared/types";
|
||||
import type { ApiClient } from "../../lib/apiClient";
|
||||
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
|
||||
import { DigitadorLiveMetrics } from "./DigitadorLiveMetrics";
|
||||
|
||||
declare const desktopApi: import("../../../shared/types").DesktopBridge | undefined;
|
||||
|
||||
@@ -284,6 +285,8 @@ export function DigitadorResultsPanel({ client, job, tasks, onStartDigitador }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DigitadorLiveMetrics events={events} />
|
||||
|
||||
<p className="wiz-hint" style={{ marginBottom: 12, flexShrink: 0 }}>
|
||||
{selected.shard_count && Number(selected.shard_count) > 1
|
||||
? `${selected.shard_count} bots (distribuido)`
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiClient } from "../../lib/apiClient";
|
||||
import { formatDateTime } from "../../lib/time";
|
||||
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
|
||||
import { DigitadorLiveMetrics } from "./DigitadorLiveMetrics";
|
||||
import type {
|
||||
DigitadorJob,
|
||||
DigitadorJobEvent,
|
||||
@@ -627,6 +628,8 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DigitadorLiveMetrics events={events} />
|
||||
|
||||
<ParallelBotProgress shards={shards} unit="filas" />
|
||||
|
||||
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
|
||||
|
||||
@@ -5635,3 +5635,65 @@ th {
|
||||
margin-left: 28px !important;
|
||||
margin-right: 28px !important;
|
||||
}
|
||||
|
||||
/* ── Costos de API por trabajo ── */
|
||||
.job-costs-view {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 22px 28px 32px;
|
||||
}
|
||||
|
||||
.job-costs-toolbar { align-items: flex-start; display: flex; gap: 20px; justify-content: space-between; }
|
||||
.job-costs-toolbar h2 { font-size: 20px; margin: 0 0 4px; }
|
||||
.job-costs-toolbar p { color: var(--text-muted); font-size: 13px; margin: 0; }
|
||||
.job-costs-summary { display: grid; gap: 12px; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 20px 0; }
|
||||
.job-costs-summary > div { background: var(--surface, #fff); border: 1px solid var(--border); border-radius: 10px; display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; }
|
||||
.job-costs-summary span { color: var(--text-muted); font-size: 12px; }
|
||||
.job-costs-summary strong { font-size: 20px; }
|
||||
.job-cost-warning { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; color: #92400e; font-size: 12px; margin: -8px 0 16px; padding: 9px 12px; }
|
||||
.job-costs-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.job-cost-card { background: var(--surface, #fff); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.job-cost-card > summary { align-items: center; cursor: pointer; display: grid; gap: 16px; grid-template-columns: minmax(220px, 1.5fr) auto auto minmax(100px, auto); list-style-position: inside; padding: 14px 16px; }
|
||||
.job-cost-process { display: inline-flex; flex-direction: column; min-width: 0; }
|
||||
.job-cost-process span { color: var(--text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.job-cost-api-badge { background: #f1f5f9; border-radius: 999px; color: #64748b; font-size: 11px; padding: 4px 8px; }
|
||||
.job-cost-api-badge.has-usage { background: #dcfce7; color: #15803d; }
|
||||
.job-cost-total { color: #0f766e; text-align: right; }
|
||||
.job-cost-detail { border-top: 1px solid var(--border); padding: 12px 16px 16px; }
|
||||
.job-cost-table { border-collapse: collapse; font-size: 12px; width: 100%; }
|
||||
.job-cost-table th, .job-cost-table td { border-bottom: 1px solid var(--border); padding: 9px 10px; text-align: right; }
|
||||
.job-cost-table th:first-child, .job-cost-table td:first-child { text-align: left; }
|
||||
.job-cost-table td small { color: var(--text-muted); display: block; font-size: 10px; margin-top: 2px; }
|
||||
.job-cost-calls { margin-top: 14px; }
|
||||
.job-cost-calls > summary { color: #2563eb; cursor: pointer; font-size: 12px; font-weight: 600; }
|
||||
.job-cost-calls-scroll { margin-top: 8px; max-height: 340px; overflow: auto; }
|
||||
.job-cost-no-api, .job-costs-empty { color: var(--text-muted); font-size: 13px; }
|
||||
.job-costs-empty { align-items: center; border: 1px dashed var(--border); border-radius: 10px; display: flex; flex-direction: column; padding: 40px; }
|
||||
@media (max-width: 900px) {
|
||||
.job-costs-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.job-cost-card > summary { grid-template-columns: 1fr auto; }
|
||||
.job-cost-card > summary > span:not(.job-cost-api-badge) { display: none; }
|
||||
}
|
||||
|
||||
.digitador-live-metrics {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 4px 0 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.digitador-live-metrics > div {
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.digitador-live-metrics span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; }
|
||||
.digitador-live-metrics strong { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -5,4 +5,5 @@ export type JobSection =
|
||||
| "poblacion_apta"
|
||||
| "downloads"
|
||||
| "digitador"
|
||||
| "costs"
|
||||
| "results";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { Task } from "../../shared/types";
|
||||
import { JobCostsView } from "./JobCostsView";
|
||||
|
||||
function task(result: Task["result"]): Task {
|
||||
return {
|
||||
id: "task-1",
|
||||
type: "extraction",
|
||||
status: "completed",
|
||||
title: "Extracción facturas",
|
||||
request: {},
|
||||
result,
|
||||
error: null,
|
||||
owner_id: null,
|
||||
job_id: "job-1",
|
||||
created_at: "2026-08-30T12:00:00Z",
|
||||
updated_at: "2026-08-30T12:01:00Z",
|
||||
started_at: "2026-08-30T12:00:00Z",
|
||||
finished_at: "2026-08-30T12:01:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("JobCostsView", () => {
|
||||
it("shows real API usage totals for a process", () => {
|
||||
render(
|
||||
<JobCostsView
|
||||
refreshing={false}
|
||||
onRefresh={() => undefined}
|
||||
tasks={[task({
|
||||
api_usage: {
|
||||
summary: { requests: 2, total_tokens: 1250, total_cost_usd: 0.00125 },
|
||||
phases: {
|
||||
extract: { requests: 2, prompt_tokens: 1000, completion_tokens: 250, total_cost_usd: 0.00125, model: "gpt-5.6-luna" },
|
||||
},
|
||||
},
|
||||
})]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Extracción facturas")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 llamadas")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("1.250").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("$0.00125").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows processes that did not consume a priced API", () => {
|
||||
render(<JobCostsView refreshing={false} onRefresh={() => undefined} tasks={[task({})]} />);
|
||||
expect(screen.getByText("Sin consumo API")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Coins, RefreshCw } from "lucide-react";
|
||||
import type { Task } from "../../shared/types";
|
||||
|
||||
type UsageSummary = {
|
||||
requests?: number;
|
||||
prompt_tokens?: number;
|
||||
cached_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
input_usd?: number;
|
||||
cached_input_usd?: number;
|
||||
output_usd?: number;
|
||||
total_cost_usd?: number;
|
||||
unpriced_requests?: number;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type UsageDetail = UsageSummary & {
|
||||
timestamp?: string;
|
||||
phase?: string;
|
||||
model?: string;
|
||||
radicado?: string | null;
|
||||
filename?: string | null;
|
||||
cost?: { total_usd?: number };
|
||||
pricing_known?: boolean;
|
||||
};
|
||||
|
||||
type ApiUsage = {
|
||||
summary?: UsageSummary;
|
||||
phases?: Record<string, UsageSummary>;
|
||||
details?: UsageDetail[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
tasks: Task[];
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const PROCESS_NAMES: Record<string, string> = {
|
||||
counting: "Conteo",
|
||||
extraction: "Extracción",
|
||||
cleaning: "Limpieza",
|
||||
poblacion_apta: "Población apta",
|
||||
downloads: "Bot de descarga",
|
||||
digitador: "Bot digitador",
|
||||
};
|
||||
|
||||
function usageOf(task: Task): ApiUsage {
|
||||
const value = task.result?.api_usage;
|
||||
return value && typeof value === "object" ? value as ApiUsage : {};
|
||||
}
|
||||
|
||||
function n(value: unknown): number {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function usd(value: unknown): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 6,
|
||||
}).format(n(value));
|
||||
}
|
||||
|
||||
function integer(value: unknown): string {
|
||||
return new Intl.NumberFormat("es-CO").format(n(value));
|
||||
}
|
||||
|
||||
function phaseName(phase: string): string {
|
||||
if (phase === "extract") return "Extracción LLM";
|
||||
if (phase === "normalize") return "Normalización LLM";
|
||||
return phase;
|
||||
}
|
||||
|
||||
export function JobCostsView({ tasks, refreshing, onRefresh }: Props) {
|
||||
const ordered = [...tasks].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
const summaries = ordered.map((task) => usageOf(task).summary ?? {});
|
||||
const totalCost = summaries.reduce((sum, item) => sum + n(item.total_cost_usd), 0);
|
||||
const totalTokens = summaries.reduce((sum, item) => sum + n(item.total_tokens), 0);
|
||||
const totalRequests = summaries.reduce((sum, item) => sum + n(item.requests), 0);
|
||||
const unpricedRequests = summaries.reduce((sum, item) => sum + n(item.unpriced_requests), 0);
|
||||
|
||||
return (
|
||||
<div className="job-costs-view">
|
||||
<div className="job-costs-toolbar">
|
||||
<div>
|
||||
<h2>Costos de API por proceso</h2>
|
||||
<p>Uso real reportado por el proveedor. Los valores históricos conservan la tarifa aplicada al procesar.</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="job-costs-summary">
|
||||
<div><span>Costo total</span><strong>{usd(totalCost)}</strong></div>
|
||||
<div><span>Tokens</span><strong>{integer(totalTokens)}</strong></div>
|
||||
<div><span>Llamadas API</span><strong>{integer(totalRequests)}</strong></div>
|
||||
<div><span>Procesos</span><strong>{integer(tasks.length)}</strong></div>
|
||||
</div>
|
||||
|
||||
{unpricedRequests > 0 && (
|
||||
<div className="job-cost-warning">
|
||||
{integer(unpricedRequests)} llamadas tienen un modelo sin tarifa configurada; sus tokens se muestran, pero no se suman al costo.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ordered.length === 0 ? (
|
||||
<div className="job-costs-empty"><Coins size={28} /><p>Aún no hay procesos en este trabajo.</p></div>
|
||||
) : (
|
||||
<div className="job-costs-list">
|
||||
{ordered.map((task) => {
|
||||
const apiUsage = usageOf(task);
|
||||
const summary = apiUsage.summary ?? {};
|
||||
const phases = Object.entries(apiUsage.phases ?? {});
|
||||
const details = apiUsage.details ?? [];
|
||||
const hasUsage = n(summary.requests) > 0;
|
||||
return (
|
||||
<details className="job-cost-card" key={task.id}>
|
||||
<summary>
|
||||
<div className="job-cost-process">
|
||||
<strong>{PROCESS_NAMES[task.type] ?? task.type}</strong>
|
||||
<span>{task.title || `Proceso ${task.id.slice(0, 8)}`}</span>
|
||||
</div>
|
||||
<span className={`job-cost-api-badge${hasUsage ? " has-usage" : ""}`}>
|
||||
{hasUsage ? `${integer(summary.requests)} llamadas` : "Sin consumo API"}
|
||||
</span>
|
||||
<span>{integer(summary.total_tokens)} tokens</span>
|
||||
<strong className="job-cost-total">{usd(summary.total_cost_usd)}</strong>
|
||||
</summary>
|
||||
|
||||
<div className="job-cost-detail">
|
||||
{hasUsage ? (
|
||||
<>
|
||||
<table className="job-cost-table">
|
||||
<thead><tr><th>Fase / modelo</th><th>Llamadas</th><th>Entrada</th><th>En caché</th><th>Salida</th><th>Costo</th></tr></thead>
|
||||
<tbody>
|
||||
{phases.map(([phase, item]) => (
|
||||
<tr key={phase}>
|
||||
<td><strong>{phaseName(phase)}</strong><small>{item.model || "Modelo no informado"}</small></td>
|
||||
<td>{integer(item.requests)}</td>
|
||||
<td>{integer(item.prompt_tokens)}</td>
|
||||
<td>{integer(item.cached_tokens)}</td>
|
||||
<td>{integer(item.completion_tokens)}</td>
|
||||
<td>{usd(item.total_cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{details.length > 0 && (
|
||||
<details className="job-cost-calls">
|
||||
<summary>Ver detalle de {details.length} llamadas</summary>
|
||||
<div className="job-cost-calls-scroll">
|
||||
<table className="job-cost-table">
|
||||
<thead><tr><th>Documento</th><th>Fase</th><th>Modelo</th><th>Entrada</th><th>Caché</th><th>Salida</th><th>Costo</th></tr></thead>
|
||||
<tbody>
|
||||
{details.map((item, index) => (
|
||||
<tr key={`${item.timestamp ?? "call"}-${index}`}>
|
||||
<td>{item.radicado || item.filename || "—"}<small>{item.radicado && item.filename ? item.filename : ""}</small></td>
|
||||
<td>{phaseName(item.phase || "API")}</td>
|
||||
<td>{item.model || "—"}</td>
|
||||
<td>{integer(item.prompt_tokens)}</td>
|
||||
<td>{integer(item.cached_tokens)}</td>
|
||||
<td>{integer(item.completion_tokens)}</td>
|
||||
<td>{item.pricing_known === false ? "Sin tarifa" : usd(item.cost?.total_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="job-cost-no-api">Este proceso no registró llamadas a una API LLM tarifada.</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { JobExtractionWizard } from "../modules/extraction/JobExtractionWizard";
|
||||
import { DownloadsWizardModule } from "../modules/downloads/DownloadsWizardModule";
|
||||
import { DigitadorWizardModule } from "../modules/digitador/DigitadorWizardModule";
|
||||
import { JobResultsView } from "./JobResultsView";
|
||||
import { JobCostsView } from "./JobCostsView";
|
||||
|
||||
interface Props {
|
||||
client: ApiClient;
|
||||
@@ -30,6 +31,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
|
||||
const [extractionProgress, setExtractionProgress] = useState<BackgroundProgress | null>(null);
|
||||
const [cleaningProgress, setCleaningProgress] = useState<BackgroundProgress | null>(null);
|
||||
const [poblacionAptaProgress, setPoblacionAptaProgress] = useState<BackgroundProgress | null>(null);
|
||||
const [costsRefreshing, setCostsRefreshing] = useState(false);
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const backgroundRunning =
|
||||
@@ -45,6 +47,11 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
|
||||
} catch { /* silent */ }
|
||||
}, [client, job.id]);
|
||||
|
||||
const refreshCosts = useCallback(async () => {
|
||||
setCostsRefreshing(true);
|
||||
try { await loadTasks(); } finally { setCostsRefreshing(false); }
|
||||
}, [loadTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
|
||||
@@ -129,6 +136,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
|
||||
{section === "poblacion_apta" && "Población apta"}
|
||||
{section === "downloads" && "Bot de descarga"}
|
||||
{section === "digitador" && "Bot digitador"}
|
||||
{section === "costs" && "Costos de API"}
|
||||
{section === "results" && "Resultados del trabajo"}
|
||||
</div>
|
||||
</header>
|
||||
@@ -236,6 +244,10 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
|
||||
onContinueToDigitador={(ctx) => onActivateSection("digitador", ctx)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`job-section-pane${section === "costs" ? "" : " job-section-pane--hidden"}`}>
|
||||
<JobCostsView tasks={tasks} refreshing={costsRefreshing} onRefresh={() => void refreshCosts()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ from domain.normalizer import normalize_to_schema
|
||||
from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.service import InvoiceProcessingService
|
||||
from domain.config import settings
|
||||
from domain.usage_tracking import record_api_usage
|
||||
from infrastructure.database import ExtractionRow, Task, utcnow
|
||||
from infrastructure.serialization import flatten_extraction_result
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -276,7 +278,7 @@ def process_desktop_ingest(
|
||||
else:
|
||||
prompt = instruction or PromptBuilder.build(selected_paths, {}, profile_name=prof.get("name", "invoice"))
|
||||
|
||||
data, _usage = service.extract_full_from_pdf(
|
||||
data, extraction_usage = service.extract_full_from_pdf(
|
||||
temp_path,
|
||||
instruction=prompt,
|
||||
tiff_page_range=tiff_page_range,
|
||||
@@ -292,8 +294,28 @@ def process_desktop_ingest(
|
||||
profile_name=prof.get("name", "invoice"),
|
||||
)
|
||||
normalized_obj = data
|
||||
normalizer_usage = None
|
||||
if isinstance(norm, dict):
|
||||
normalized_obj = norm.get("normalized") or data
|
||||
normalizer_usage = norm.get("_usage")
|
||||
|
||||
usage_result = record_api_usage(
|
||||
dict(task.result or {}),
|
||||
phase="extract",
|
||||
model=str(getattr(service.extractor, "model", model or "unknown")),
|
||||
usage=extraction_usage,
|
||||
radicado=radicado,
|
||||
filename=filename,
|
||||
)
|
||||
usage_result = record_api_usage(
|
||||
usage_result,
|
||||
phase="normalize",
|
||||
model=str(settings.api.normalizer_model),
|
||||
usage=normalizer_usage,
|
||||
radicado=radicado,
|
||||
filename=filename,
|
||||
)
|
||||
task.result = usage_result
|
||||
|
||||
return persist_desktop_ingest_result(
|
||||
session,
|
||||
|
||||
@@ -228,6 +228,28 @@ def serialize_task(task: Task) -> dict[str, Any]:
|
||||
|
||||
|
||||
def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
usage_result: dict[str, Any] = {}
|
||||
|
||||
def capture_usage(
|
||||
phase: str,
|
||||
radicado: str,
|
||||
usage: dict[str, Any] | None,
|
||||
_cost: dict[str, Any] | None,
|
||||
) -> None:
|
||||
nonlocal usage_result
|
||||
model = request.model or settings.api.extractor_model
|
||||
if phase == "normalize":
|
||||
model = settings.api.normalizer_model
|
||||
usage_result = record_api_usage(
|
||||
usage_result,
|
||||
phase=phase,
|
||||
model=str(model),
|
||||
usage=usage,
|
||||
radicado=radicado,
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
batch = ExtractionBatch(
|
||||
status="running",
|
||||
@@ -252,6 +274,7 @@ def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
|
||||
structure_mode=request.structure_mode,
|
||||
retry_on_empty=request.retry_on_empty,
|
||||
radicado_filter=request.radicado_filter,
|
||||
on_usage=capture_usage,
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
@@ -279,6 +302,7 @@ def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
|
||||
session.flush()
|
||||
serialized = serialize_batch(batch)
|
||||
serialized["row_count"] = row_index
|
||||
serialized["api_usage"] = usage_result.get("api_usage", {})
|
||||
return serialized
|
||||
except Exception as exc:
|
||||
with session_scope() as session:
|
||||
@@ -312,6 +336,7 @@ def run_extraction_task(task_id: str) -> None:
|
||||
"extraction_batch_id": batch["id"],
|
||||
"row_count": batch.get("row_count", 0),
|
||||
"batch": batch,
|
||||
"api_usage": batch.get("api_usage", {}),
|
||||
}
|
||||
task.error = None
|
||||
task.finished_at = utcnow()
|
||||
@@ -620,6 +645,8 @@ async def _process_sync(
|
||||
from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.normalizer import normalize_to_schema
|
||||
from domain.config import settings as _settings
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
_log = _logging.getLogger("extraction.ingest")
|
||||
|
||||
@@ -656,13 +683,13 @@ async def _process_sync(
|
||||
tiff_range = req.get("tiff_page_range")
|
||||
model_name = getattr(svc.extractor, "model", "?")
|
||||
_log.info("→ OpenAI [%s] radicado=%s filename=%s", model_name, radicado, effective_name)
|
||||
data, _usage = svc.extract_full_from_pdf(
|
||||
data, extraction_usage = svc.extract_full_from_pdf(
|
||||
temp_path,
|
||||
instruction=prompt,
|
||||
tiff_page_range=tiff_range,
|
||||
)
|
||||
tok_in = (_usage or {}).get("prompt_tokens", "?")
|
||||
tok_out = (_usage or {}).get("completion_tokens", "?")
|
||||
tok_in = (extraction_usage or {}).get("prompt_tokens", "?")
|
||||
tok_out = (extraction_usage or {}).get("completion_tokens", "?")
|
||||
_log.info("← OpenAI [%s] radicado=%s tokens=%s→%s", model_name, radicado, tok_in, tok_out)
|
||||
if isinstance(data, dict):
|
||||
data["radicado"] = radicado
|
||||
@@ -670,11 +697,13 @@ async def _process_sync(
|
||||
data = {"radicado": radicado, "data": data}
|
||||
norm = normalize_to_schema(data, paths=selected_paths, profile_name=prof.get("name", "invoice"))
|
||||
normalized_obj = data
|
||||
normalizer_usage = None
|
||||
if isinstance(norm, dict):
|
||||
normalized_obj = norm.get("normalized") or data
|
||||
return normalized_obj, selected_paths
|
||||
normalizer_usage = norm.get("_usage")
|
||||
return normalized_obj, selected_paths, extraction_usage, normalizer_usage, model_name
|
||||
|
||||
normalized_obj, selected_paths = await _asyncio.to_thread(_call_llm)
|
||||
normalized_obj, selected_paths, extraction_usage, normalizer_usage, model_name = await _asyncio.to_thread(_call_llm)
|
||||
|
||||
# ── Fase 2: persistir resultado (con FOR UPDATE para serializar) ───
|
||||
def _persist():
|
||||
@@ -682,6 +711,14 @@ async def _process_sync(
|
||||
task = session.get(Task, task_id, with_for_update=True)
|
||||
if task is None:
|
||||
return {"error": "task_not_found"}
|
||||
task.result = record_api_usage(
|
||||
dict(task.result or {}), phase="extract", model=str(model_name),
|
||||
usage=extraction_usage, radicado=radicado, filename=effective_name,
|
||||
)
|
||||
task.result = record_api_usage(
|
||||
dict(task.result or {}), phase="normalize", model=str(_settings.api.normalizer_model),
|
||||
usage=normalizer_usage, radicado=radicado, filename=effective_name,
|
||||
)
|
||||
payload = persist_desktop_ingest_result(
|
||||
session,
|
||||
task=task,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Persistencia compacta del uso real de APIs LLM dentro del resultado de una tarea."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from domain.costs import MODEL_PRICING, compute_cost_from_usage
|
||||
|
||||
|
||||
def _number(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _tokens(usage: dict[str, Any], key: str) -> int:
|
||||
try:
|
||||
return int(usage.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def record_api_usage(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
phase: str,
|
||||
model: str,
|
||||
usage: dict[str, Any] | None,
|
||||
radicado: str | None = None,
|
||||
filename: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Agrega una llamada facturable a ``result.api_usage`` y devuelve una copia.
|
||||
|
||||
El proveedor es la fuente de los tokens. El costo se calcula con la tabla de
|
||||
precios configurada para el modelo y se conserva separado por fase y llamada.
|
||||
"""
|
||||
if not isinstance(usage, dict):
|
||||
return dict(result)
|
||||
|
||||
next_result = dict(result)
|
||||
ledger = dict(next_result.get("api_usage") or {})
|
||||
summary = dict(ledger.get("summary") or {})
|
||||
phases = dict(ledger.get("phases") or {})
|
||||
phase_summary = dict(phases.get(phase) or {})
|
||||
details = list(ledger.get("details") or [])
|
||||
|
||||
prompt_tokens = _tokens(usage, "prompt_tokens")
|
||||
completion_tokens = _tokens(usage, "completion_tokens")
|
||||
token_details = usage.get("prompt_tokens_details") or {}
|
||||
cached_tokens = _tokens(token_details, "cached_tokens") if isinstance(token_details, dict) else 0
|
||||
total_tokens = _tokens(usage, "total_tokens") or prompt_tokens + completion_tokens
|
||||
cost = compute_cost_from_usage(usage, phase=phase, model=model) or {}
|
||||
|
||||
values: dict[str, int | float] = {
|
||||
"requests": 1,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"cached_tokens": cached_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"input_usd": _number(cost.get("input_usd")),
|
||||
"cached_input_usd": _number(cost.get("cached_input_usd")),
|
||||
"output_usd": _number(cost.get("output_usd")),
|
||||
"total_cost_usd": _number(cost.get("total_usd")),
|
||||
"unpriced_requests": 0 if model in MODEL_PRICING else 1,
|
||||
}
|
||||
for key, value in values.items():
|
||||
precision = 6 if key.endswith("usd") else None
|
||||
summary[key] = round(_number(summary.get(key)) + value, precision) if precision else int(_number(summary.get(key)) + value)
|
||||
phase_summary[key] = round(_number(phase_summary.get(key)) + value, precision) if precision else int(_number(phase_summary.get(key)) + value)
|
||||
|
||||
phase_summary["model"] = model
|
||||
phases[phase] = phase_summary
|
||||
details.append({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"phase": phase,
|
||||
"model": model,
|
||||
"radicado": radicado,
|
||||
"filename": filename,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"cached_tokens": cached_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"cost": cost,
|
||||
"pricing_known": model in MODEL_PRICING,
|
||||
})
|
||||
|
||||
ledger.update({"summary": summary, "phases": phases, "details": details})
|
||||
next_result["api_usage"] = ledger
|
||||
return next_result
|
||||
@@ -0,0 +1,51 @@
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
|
||||
def test_record_api_usage_aggregates_summary_phases_and_details():
|
||||
result = record_api_usage(
|
||||
{},
|
||||
phase="extract",
|
||||
model="gpt-5.6-luna",
|
||||
usage={
|
||||
"prompt_tokens": 1_000,
|
||||
"completion_tokens": 200,
|
||||
"total_tokens": 1_200,
|
||||
"prompt_tokens_details": {"cached_tokens": 100},
|
||||
},
|
||||
radicado="RAD-1",
|
||||
filename="factura.pdf",
|
||||
)
|
||||
result = record_api_usage(
|
||||
result,
|
||||
phase="extract",
|
||||
model="gpt-5.6-luna",
|
||||
usage={"prompt_tokens": 500, "completion_tokens": 50},
|
||||
radicado="RAD-2",
|
||||
)
|
||||
|
||||
ledger = result["api_usage"]
|
||||
assert ledger["summary"]["requests"] == 2
|
||||
assert ledger["summary"]["prompt_tokens"] == 1_500
|
||||
assert ledger["summary"]["completion_tokens"] == 250
|
||||
assert ledger["summary"]["total_tokens"] == 1_750
|
||||
assert ledger["summary"]["unpriced_requests"] == 0
|
||||
assert ledger["phases"]["extract"]["requests"] == 2
|
||||
assert ledger["details"][0]["filename"] == "factura.pdf"
|
||||
assert ledger["details"][0]["pricing_known"] is True
|
||||
|
||||
|
||||
def test_record_api_usage_marks_unknown_model_as_unpriced():
|
||||
result = record_api_usage(
|
||||
{},
|
||||
phase="extract",
|
||||
model="provider/model-without-price",
|
||||
usage={"prompt_tokens": 100, "completion_tokens": 10},
|
||||
)
|
||||
|
||||
assert result["api_usage"]["summary"]["unpriced_requests"] == 1
|
||||
assert result["api_usage"]["details"][0]["pricing_known"] is False
|
||||
|
||||
|
||||
def test_record_api_usage_ignores_missing_usage():
|
||||
original = {"processed": 1}
|
||||
assert record_api_usage(original, phase="extract", model="gpt-5.6-luna", usage=None) == original
|
||||
@@ -24,6 +24,8 @@ from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.normalizer import normalize_to_schema
|
||||
from domain.rate_limiter import get_rate_limiter
|
||||
from domain.config import settings
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,8 +133,8 @@ async def process_document(
|
||||
await rate_limiter.acquire(estimated_tokens=estimated_tokens)
|
||||
|
||||
# Extracción + normalización (CPU + IO bound, en thread pool)
|
||||
def _extract_and_normalize() -> tuple[dict, dict]:
|
||||
data, _usage = service.extract_full_from_pdf(
|
||||
def _extract_and_normalize() -> tuple[dict, dict, dict | None, dict | None]:
|
||||
data, extraction_usage = service.extract_full_from_pdf(
|
||||
queue_path,
|
||||
instruction=prompt,
|
||||
tiff_page_range=tiff_range,
|
||||
@@ -149,11 +151,13 @@ async def process_document(
|
||||
profile_name=prof.get("name", "invoice"),
|
||||
)
|
||||
normalized_obj = data
|
||||
normalizer_usage = None
|
||||
if isinstance(norm, dict):
|
||||
normalized_obj = norm.get("normalized") or data
|
||||
return raw_data, normalized_obj
|
||||
normalizer_usage = norm.get("_usage")
|
||||
return raw_data, normalized_obj, extraction_usage, normalizer_usage
|
||||
|
||||
_raw, normalized_obj = await asyncio.to_thread(_extract_and_normalize)
|
||||
_raw, normalized_obj, extraction_usage, normalizer_usage = await asyncio.to_thread(_extract_and_normalize)
|
||||
|
||||
# Persistir resultado en BD
|
||||
def _persist() -> dict[str, Any]:
|
||||
@@ -164,6 +168,15 @@ async def process_document(
|
||||
return {"skipped": True, "reason": "task_not_found"}
|
||||
|
||||
result = dict(task.result or {})
|
||||
result = record_api_usage(
|
||||
result, phase="extract", model=str(effective_model), usage=extraction_usage,
|
||||
radicado=radicado, filename=filename,
|
||||
)
|
||||
result = record_api_usage(
|
||||
result, phase="normalize", model=str(settings.api.normalizer_model), usage=normalizer_usage,
|
||||
radicado=radicado, filename=filename,
|
||||
)
|
||||
task.result = result
|
||||
|
||||
if structure_mode == "radicados" and doc_types and doc_type:
|
||||
partials = dict(result.get("partials") or {})
|
||||
|
||||
Reference in New Issue
Block a user