diff --git a/bots/bot_descarga.zip b/bots/bot_descarga.zip index 1d825be..31717b6 100644 Binary files a/bots/bot_descarga.zip and b/bots/bot_descarga.zip differ diff --git a/bots/bot_digitador.zip b/bots/bot_digitador.zip new file mode 100644 index 0000000..f5d6f0a Binary files /dev/null and b/bots/bot_digitador.zip differ diff --git a/bots/bot_digitador/.env.example b/bots/bot_digitador/.env.example index 2405548..b7c9f62 100644 --- a/bots/bot_digitador/.env.example +++ b/bots/bot_digitador/.env.example @@ -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 diff --git a/bots/bot_digitador/README.md b/bots/bot_digitador/README.md index 14a998e..41be7b8 100644 --- a/bots/bot_digitador/README.md +++ b/bots/bot_digitador/README.md @@ -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` diff --git a/bots/bot_digitador/bot_digitador/core/application.py b/bots/bot_digitador/bot_digitador/core/application.py new file mode 100644 index 0000000..3590abb --- /dev/null +++ b/bots/bot_digitador/bot_digitador/core/application.py @@ -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 + diff --git a/bots/bot_digitador/bot_digitador/core/config.py b/bots/bot_digitador/bot_digitador/core/config.py new file mode 100644 index 0000000..efa489c --- /dev/null +++ b/bots/bot_digitador/bot_digitador/core/config.py @@ -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 +) + diff --git a/bots/bot_digitador/bot_digitador/core/coords.py b/bots/bot_digitador/bot_digitador/core/coords.py index 49edac2..7fbb852 100644 --- a/bots/bot_digitador/bot_digitador/core/coords.py +++ b/bots/bot_digitador/bot_digitador/core/coords.py @@ -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 diff --git a/bots/bot_digitador/bot_digitador/core/csv_rules.py b/bots/bot_digitador/bot_digitador/core/csv_rules.py new file mode 100644 index 0000000..02f1d50 --- /dev/null +++ b/bots/bot_digitador/bot_digitador/core/csv_rules.py @@ -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 + diff --git a/bots/bot_digitador/bot_digitador/core/digitador_service.py b/bots/bot_digitador/bot_digitador/core/digitador_service.py index a431d36..37abe9a 100644 --- a/bots/bot_digitador/bot_digitador/core/digitador_service.py +++ b/bots/bot_digitador/bot_digitador/core/digitador_service.py @@ -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 diff --git a/bots/bot_digitador/bot_digitador/core/reports.py b/bots/bot_digitador/bot_digitador/core/reports.py new file mode 100644 index 0000000..2dfc1f8 --- /dev/null +++ b/bots/bot_digitador/bot_digitador/core/reports.py @@ -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) diff --git a/bots/bot_digitador/bot_digitador/core/vision.py b/bots/bot_digitador/bot_digitador/core/vision.py new file mode 100644 index 0000000..8c7136a --- /dev/null +++ b/bots/bot_digitador/bot_digitador/core/vision.py @@ -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." + ) + diff --git a/bots/bot_digitador/bot_digitador/ui/dashboard.py b/bots/bot_digitador/bot_digitador/ui/dashboard.py index 06dffc0..26cba2b 100644 --- a/bots/bot_digitador/bot_digitador/ui/dashboard.py +++ b/bots/bot_digitador/bot_digitador/ui/dashboard.py @@ -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() diff --git a/bots/bot_digitador/bot_digitador/ui/progress_overlay.py b/bots/bot_digitador/bot_digitador/ui/progress_overlay.py new file mode 100644 index 0000000..8b7519b --- /dev/null +++ b/bots/bot_digitador/bot_digitador/ui/progress_overlay.py @@ -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 diff --git a/bots/bot_digitador/bot_digitador/worker/agent.py b/bots/bot_digitador/bot_digitador/worker/agent.py index 435ca14..7126515 100644 --- a/bots/bot_digitador/bot_digitador/worker/agent.py +++ b/bots/bot_digitador/bot_digitador/worker/agent.py @@ -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: diff --git a/bots/bot_digitador/scripts/package_bot.py b/bots/bot_digitador/scripts/package_bot.py new file mode 100644 index 0000000..ac2a50a --- /dev/null +++ b/bots/bot_digitador/scripts/package_bot.py @@ -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()) diff --git a/bots/bot_digitador/scripts/publish_bot.py b/bots/bot_digitador/scripts/publish_bot.py new file mode 100644 index 0000000..4e51494 --- /dev/null +++ b/bots/bot_digitador/scripts/publish_bot.py @@ -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()) diff --git a/bots/bot_digitador/template/README.md b/bots/bot_digitador/template/README.md index 63139f6..3029462 100644 --- a/bots/bot_digitador/template/README.md +++ b/bots/bot_digitador/template/README.md @@ -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. diff --git a/bots/bot_digitador/tests/conftest.py b/bots/bot_digitador/tests/conftest.py new file mode 100644 index 0000000..e4370f1 --- /dev/null +++ b/bots/bot_digitador/tests/conftest.py @@ -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)) + diff --git a/bots/bot_digitador/tests/test_application.py b/bots/bot_digitador/tests/test_application.py new file mode 100644 index 0000000..a0cfed1 --- /dev/null +++ b/bots/bot_digitador/tests/test_application.py @@ -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 + diff --git a/bots/bot_digitador/tests/test_config.py b/bots/bot_digitador/tests/test_config.py new file mode 100644 index 0000000..526fe72 --- /dev/null +++ b/bots/bot_digitador/tests/test_config.py @@ -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" diff --git a/bots/bot_digitador/tests/test_csv_rules.py b/bots/bot_digitador/tests/test_csv_rules.py new file mode 100644 index 0000000..820a16b --- /dev/null +++ b/bots/bot_digitador/tests/test_csv_rules.py @@ -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("") == [] + diff --git a/bots/bot_digitador/tests/test_packaging.py b/bots/bot_digitador/tests/test_packaging.py new file mode 100644 index 0000000..fdf9005 --- /dev/null +++ b/bots/bot_digitador/tests/test_packaging.py @@ -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) + diff --git a/bots/bot_digitador/tests/test_progress_overlay.py b/bots/bot_digitador/tests/test_progress_overlay.py new file mode 100644 index 0000000..0a36ccd --- /dev/null +++ b/bots/bot_digitador/tests/test_progress_overlay.py @@ -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" diff --git a/bots/bot_digitador/tests/test_reports_and_vision.py b/bots/bot_digitador/tests/test_reports_and_vision.py new file mode 100644 index 0000000..58e345a --- /dev/null +++ b/bots/bot_digitador/tests/test_reports_and_vision.py @@ -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() + diff --git a/bots/bot_digitador/tests/test_service_metrics.py b/bots/bot_digitador/tests/test_service_metrics.py new file mode 100644 index 0000000..332d4dc --- /dev/null +++ b/bots/bot_digitador/tests/test_service_metrics.py @@ -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" diff --git a/services/bot_digitador/api/main.py b/services/bot_digitador/api/main.py index 94256b9..5cdd696 100644 --- a/services/bot_digitador/api/main.py +++ b/services/bot_digitador/api/main.py @@ -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) diff --git a/services/bot_digitador/infrastructure/store.py b/services/bot_digitador/infrastructure/store.py index 6ec2374..97c6123 100644 --- a/services/bot_digitador/infrastructure/store.py +++ b/services/bot_digitador/infrastructure/store.py @@ -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": [ { diff --git a/services/bot_digitador/tests/test_api_overlay.py b/services/bot_digitador/tests/test_api_overlay.py new file mode 100644 index 0000000..f5f99b6 --- /dev/null +++ b/services/bot_digitador/tests/test_api_overlay.py @@ -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 diff --git a/services/bot_digitador/tests/test_store.py b/services/bot_digitador/tests/test_store.py index 210d462..31bb1a0 100644 --- a/services/bot_digitador/tests/test_store.py +++ b/services/bot_digitador/tests/test_store.py @@ -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 diff --git a/services/desktop_app/package.json b/services/desktop_app/package.json index 86d5b96..cfd452b 100644 --- a/services/desktop_app/package.json +++ b/services/desktop_app/package.json @@ -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", diff --git a/services/desktop_app/src/renderer/App.test.tsx b/services/desktop_app/src/renderer/App.test.tsx index 77efe87..941d85a 100644 --- a/services/desktop_app/src/renderer/App.test.tsx +++ b/services/desktop_app/src/renderer/App.test.tsx @@ -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(); + + 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", + ); + }); }); diff --git a/services/desktop_app/src/renderer/App.tsx b/services/desktop_app/src/renderer/App.tsx index 5af0624..3caa05e 100644 --- a/services/desktop_app/src/renderer/App.tsx +++ b/services/desktop_app/src/renderer/App.tsx @@ -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(null); + const [digitadorDownloadBusy, setDigitadorDownloadBusy] = useState(false); + const [digitadorDownloadError, setDigitadorDownloadError] = useState(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() { Población apta +
+ + {digitadorMenuOpen && ( +
+ +
+ )} +
+ + +
+
Costo total{usd(totalCost)}
+
Tokens{integer(totalTokens)}
+
Llamadas API{integer(totalRequests)}
+
Procesos{integer(tasks.length)}
+
+ + {unpricedRequests > 0 && ( +
+ {integer(unpricedRequests)} llamadas tienen un modelo sin tarifa configurada; sus tokens se muestran, pero no se suman al costo. +
+ )} + + {ordered.length === 0 ? ( +

Aún no hay procesos en este trabajo.

+ ) : ( +
+ {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 ( +
+ +
+ {PROCESS_NAMES[task.type] ?? task.type} + {task.title || `Proceso ${task.id.slice(0, 8)}`} +
+ + {hasUsage ? `${integer(summary.requests)} llamadas` : "Sin consumo API"} + + {integer(summary.total_tokens)} tokens + {usd(summary.total_cost_usd)} +
+ +
+ {hasUsage ? ( + <> + + + + {phases.map(([phase, item]) => ( + + + + + + + + + ))} + +
Fase / modeloLlamadasEntradaEn cachéSalidaCosto
{phaseName(phase)}{item.model || "Modelo no informado"}{integer(item.requests)}{integer(item.prompt_tokens)}{integer(item.cached_tokens)}{integer(item.completion_tokens)}{usd(item.total_cost_usd)}
+ + {details.length > 0 && ( +
+ Ver detalle de {details.length} llamadas +
+ + + + {details.map((item, index) => ( + + + + + + + + + + ))} + +
DocumentoFaseModeloEntradaCachéSalidaCosto
{item.radicado || item.filename || "—"}{item.radicado && item.filename ? item.filename : ""}{phaseName(item.phase || "API")}{item.model || "—"}{integer(item.prompt_tokens)}{integer(item.cached_tokens)}{integer(item.completion_tokens)}{item.pricing_known === false ? "Sin tarifa" : usd(item.cost?.total_usd)}
+
+
+ )} + + ) : ( +

Este proceso no registró llamadas a una API LLM tarifada.

+ )} +
+
+ ); + })} +
+ )} + + ); +} diff --git a/services/desktop_app/src/renderer/views/JobView.tsx b/services/desktop_app/src/renderer/views/JobView.tsx index 09c7663..0fbd7ca 100644 --- a/services/desktop_app/src/renderer/views/JobView.tsx +++ b/services/desktop_app/src/renderer/views/JobView.tsx @@ -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(null); const [cleaningProgress, setCleaningProgress] = useState(null); const [poblacionAptaProgress, setPoblacionAptaProgress] = useState(null); + const [costsRefreshing, setCostsRefreshing] = useState(false); const pollingRef = useRef | 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"} @@ -236,6 +244,10 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio onContinueToDigitador={(ctx) => onActivateSection("digitador", ctx)} /> + +
+ void refreshCosts()} /> +
); diff --git a/services/extraction/api/desktop_ingest.py b/services/extraction/api/desktop_ingest.py index 2ba6fed..438f668 100644 --- a/services/extraction/api/desktop_ingest.py +++ b/services/extraction/api/desktop_ingest.py @@ -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, diff --git a/services/extraction/api/main.py b/services/extraction/api/main.py index 0c9d186..268c2bd 100644 --- a/services/extraction/api/main.py +++ b/services/extraction/api/main.py @@ -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, diff --git a/services/extraction/domain/usage_tracking.py b/services/extraction/domain/usage_tracking.py new file mode 100644 index 0000000..c9f1ea1 --- /dev/null +++ b/services/extraction/domain/usage_tracking.py @@ -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 diff --git a/services/extraction/tests/test_usage_tracking.py b/services/extraction/tests/test_usage_tracking.py new file mode 100644 index 0000000..9a92e7c --- /dev/null +++ b/services/extraction/tests/test_usage_tracking.py @@ -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 diff --git a/services/extraction/worker/tasks.py b/services/extraction/worker/tasks.py index ff00230..5e2b578 100644 --- a/services/extraction/worker/tasks.py +++ b/services/extraction/worker/tasks.py @@ -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 {})