feat: Sistema CBCFacil completo con cola secuencial
- Implementa ProcessingMonitor singleton para procesamiento secuencial de archivos - Agrega AI summary service con soporte para MiniMax API - Agrega PDF generator para resúmenes - Agrega watchers para monitoreo de carpeta remota - Mejora sistema de notificaciones Telegram - Implementa gestión de VRAM para GPU - Configuración mediante variables de entorno (sin hardcoded secrets) - .env y transcriptions/ agregados a .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
622
core/process_manager.py
Normal file
622
core/process_manager.py
Normal file
@@ -0,0 +1,622 @@
|
||||
"""
|
||||
Process Manager - Coordina el flujo watcher -> descarga -> transcripción.
|
||||
|
||||
Maneja el estado de cada archivo a través de una state machine simple:
|
||||
pending -> downloading -> transcribing -> completed -> error
|
||||
"""
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from processors.audio_processor import AudioProcessor, AudioProcessingError
|
||||
from processors.audio_processor import GPUOutOfMemoryError, TranscriptionTimeoutError
|
||||
from services.webdav_service import WebDAVService
|
||||
from services.ai_summary_service import AISummaryService
|
||||
from services.telegram_service import telegram_service
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessState(str, Enum):
|
||||
"""Estados del proceso de transcripción."""
|
||||
PENDING = "pending"
|
||||
DOWNLOADING = "downloading"
|
||||
TRANSCRIBING = "transcribing"
|
||||
COMPLETED = "completed"
|
||||
ERROR = "error"
|
||||
CLEANING = "cleaning" # Estado intermedio para limpieza de GPU
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Información del proceso de un archivo."""
|
||||
file_path: Path
|
||||
state: ProcessState = ProcessState.PENDING
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
transcript: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
|
||||
|
||||
# Callback para notificaciones
|
||||
NotificationCallback = Callable[[ProcessInfo], None]
|
||||
|
||||
|
||||
class ProcessManagerError(Exception):
|
||||
"""Error específico del ProcessManager."""
|
||||
pass
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
"""
|
||||
Coordina el flujo: watcher -> descarga -> transcripción.
|
||||
|
||||
Maneja el estado de archivos de audio a través de una máquina de estados
|
||||
simple y notifica sobre cambios mediante callbacks.
|
||||
|
||||
Attributes:
|
||||
audio_processor: Instancia de AudioProcessor para transcripciones.
|
||||
webdav_service: Instancia opcional de WebDAVService para descargas remotas.
|
||||
on_state_change: Callback llamado cuando cambia el estado de un proceso.
|
||||
on_complete: Callback llamado cuando un proceso se completa exitosamente.
|
||||
on_error: Callback llamado cuando ocurre un error en un proceso.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_processor: Optional[AudioProcessor] = None,
|
||||
webdav_service: Optional[WebDAVService] = None,
|
||||
ai_summary_service: Optional[AISummaryService] = None,
|
||||
on_state_change: Optional[NotificationCallback] = None,
|
||||
on_complete: Optional[NotificationCallback] = None,
|
||||
on_error: Optional[NotificationCallback] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Inicializa el ProcessManager.
|
||||
|
||||
Args:
|
||||
audio_processor: Procesador de audio. Se crea uno nuevo si no se provee.
|
||||
webdav_service: Servicio WebDAV para descargas remotas (opcional).
|
||||
ai_summary_service: Servicio de resumen con IA (opcional).
|
||||
on_state_change: Callback para cambios de estado.
|
||||
on_complete: Callback para procesos completados.
|
||||
on_error: Callback para errores.
|
||||
"""
|
||||
self._audio_processor = audio_processor or AudioProcessor()
|
||||
self._webdav_service = webdav_service
|
||||
self._ai_summary_service = ai_summary_service or AISummaryService()
|
||||
self._on_state_change = on_state_change
|
||||
self._on_complete = on_complete
|
||||
self._on_error = on_error
|
||||
|
||||
# Estado de procesos: file_key -> ProcessInfo
|
||||
self._processes: dict[str, ProcessInfo] = {}
|
||||
|
||||
logger.info(
|
||||
"ProcessManager inicializado",
|
||||
extra={
|
||||
"has_audio_processor": audio_processor is not None,
|
||||
"has_webdav": webdav_service is not None,
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def audio_processor(self) -> AudioProcessor:
|
||||
"""Procesador de audio configurado."""
|
||||
return self._audio_processor
|
||||
|
||||
@property
|
||||
def webdav_service(self) -> Optional[WebDAVService]:
|
||||
"""Servicio WebDAV configurado."""
|
||||
return self._webdav_service
|
||||
|
||||
@property
|
||||
def ai_summary_service(self) -> AISummaryService:
|
||||
"""Servicio de resumen con IA configurado."""
|
||||
return self._ai_summary_service
|
||||
|
||||
def process_file(self, filepath: Path) -> ProcessInfo:
|
||||
"""
|
||||
Procesa un archivo de audio: download + transcripción.
|
||||
|
||||
El método garantiza que el modelo de audio se descargará en todos
|
||||
los casos (éxito, error, timeout, etc.) mediante bloques try/finally.
|
||||
|
||||
Args:
|
||||
filepath: Ruta al archivo de audio.
|
||||
|
||||
Returns:
|
||||
ProcessInfo con el estado final del proceso.
|
||||
|
||||
Raises:
|
||||
ProcessManagerError: Si el archivo no es válido o no se puede procesar.
|
||||
"""
|
||||
file_key = str(filepath)
|
||||
logger.info(
|
||||
"Iniciando procesamiento de archivo",
|
||||
extra={"file_path": str(filepath)},
|
||||
)
|
||||
|
||||
# Crear o recuperar proceso
|
||||
if file_key in self._processes:
|
||||
process = self._processes[file_key]
|
||||
# Reiniciar si ya estaba en estado terminal
|
||||
if process.state in (ProcessState.COMPLETED, ProcessState.ERROR):
|
||||
process = ProcessInfo(file_path=filepath)
|
||||
self._processes[file_key] = process
|
||||
else:
|
||||
process = ProcessInfo(file_path=filepath)
|
||||
self._processes[file_key] = process
|
||||
|
||||
# Variable para rastrear si debemos limpiar GPU
|
||||
should_cleanup_gpu = False
|
||||
|
||||
try:
|
||||
# Validar archivo
|
||||
if not filepath.exists():
|
||||
process.state = ProcessState.ERROR
|
||||
process.error = f"Archivo no encontrado: {filepath}"
|
||||
process.updated_at = datetime.now()
|
||||
self._notify_error(process)
|
||||
logger.error(
|
||||
"Archivo no encontrado",
|
||||
extra={"file_path": str(filepath)},
|
||||
)
|
||||
raise ProcessManagerError(process.error)
|
||||
|
||||
# Obtener tamaño
|
||||
try:
|
||||
process.file_size = filepath.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Estado: downloading (asumimos que ya está disponible localmente)
|
||||
self._update_state(process, ProcessState.DOWNLOADING)
|
||||
|
||||
# Si hay WebDAV y el archivo es remoto, descargar
|
||||
if self._webdav_service and self._is_remote_path(filepath):
|
||||
try:
|
||||
self._download_from_remote(process)
|
||||
telegram_service.send_download_complete(filepath.name)
|
||||
except Exception as e:
|
||||
process.state = ProcessState.ERROR
|
||||
process.error = f"Descarga fallida: {e}"
|
||||
process.updated_at = datetime.now()
|
||||
self._notify_error(process)
|
||||
logger.error(
|
||||
"Descarga fallida",
|
||||
extra={"file_path": str(filepath), "error": str(e)},
|
||||
)
|
||||
raise ProcessManagerError(process.error) from e
|
||||
else:
|
||||
# Archivo local, notificar descarga completa
|
||||
telegram_service.send_download_complete(filepath.name)
|
||||
|
||||
# Estado: transcribing
|
||||
self._update_state(process, ProcessState.TRANSCRIBING)
|
||||
|
||||
# Notificar inicio de transcripción
|
||||
telegram_service.send_transcription_start(filepath.name)
|
||||
|
||||
# Marcar que necesitamos limpieza de GPU después de cargar el modelo
|
||||
should_cleanup_gpu = True
|
||||
|
||||
# Transcribir con manejo robusto de errores
|
||||
try:
|
||||
process.transcript = self._audio_processor.transcribe(str(filepath))
|
||||
|
||||
# Notificar transcripción completada
|
||||
transcript_length = len(process.transcript) if process.transcript else 0
|
||||
telegram_service.send_transcription_complete(filepath.name, transcript_length)
|
||||
|
||||
# Guardar transcripción en archivo .txt
|
||||
txt_path = self._save_transcription(filepath, process.transcript)
|
||||
|
||||
# Mover archivo de audio a transcriptions/
|
||||
self._move_audio_to_transcriptions(filepath)
|
||||
|
||||
# Generar resumen con IA y PDF
|
||||
md_path, pdf_path = self.generate_summary(filepath)
|
||||
|
||||
# Notificación final con todos los archivos
|
||||
telegram_service.send_all_complete(
|
||||
filename=filepath.name,
|
||||
txt_path=str(txt_path) if txt_path else None,
|
||||
md_path=str(md_path) if md_path else None,
|
||||
pdf_path=str(pdf_path) if pdf_path else None,
|
||||
)
|
||||
|
||||
process.state = ProcessState.COMPLETED
|
||||
process.updated_at = datetime.now()
|
||||
self._notify_complete(process)
|
||||
|
||||
logger.info(
|
||||
"Transcripción completada",
|
||||
extra={
|
||||
"file_path": str(filepath),
|
||||
"transcript_length": len(process.transcript or ""),
|
||||
},
|
||||
)
|
||||
|
||||
except (GPUOutOfMemoryError, TranscriptionTimeoutError) as e:
|
||||
# Estos errores ya limpian la GPU internamente, no necesitamos limpiar de nuevo
|
||||
should_cleanup_gpu = False
|
||||
|
||||
process.state = ProcessState.ERROR
|
||||
error_type = "GPU OOM" if isinstance(e, GPUOutOfMemoryError) else "Timeout"
|
||||
process.error = f"Transcripción fallida ({error_type}): {e}"
|
||||
process.updated_at = datetime.now()
|
||||
self._notify_error(process)
|
||||
|
||||
logger.error(
|
||||
f"Transcripción fallida ({error_type})",
|
||||
extra={"file_path": str(filepath), "error": str(e)},
|
||||
)
|
||||
raise ProcessManagerError(process.error) from e
|
||||
|
||||
except AudioProcessingError as e:
|
||||
process.state = ProcessState.ERROR
|
||||
process.error = f"Transcripción fallida: {e}"
|
||||
process.updated_at = datetime.now()
|
||||
self._notify_error(process)
|
||||
|
||||
logger.error(
|
||||
"Transcripción fallida",
|
||||
extra={"file_path": str(filepath), "error": str(e)},
|
||||
)
|
||||
raise ProcessManagerError(process.error) from e
|
||||
|
||||
return process
|
||||
|
||||
finally:
|
||||
# LIMPIEZA GUARANTIZADA: Siempre ejecutado, pase lo que pase
|
||||
if should_cleanup_gpu:
|
||||
self._ensure_gpu_cleanup(filepath)
|
||||
|
||||
def _ensure_gpu_cleanup(self, filepath: Path) -> None:
|
||||
"""
|
||||
Asegura que el modelo de audio se descargue de la GPU.
|
||||
|
||||
Este método se llama en el bloque finally para garantizar que
|
||||
la memoria GPU se libere sin importar cómo terminó el proceso.
|
||||
|
||||
Args:
|
||||
filepath: Ruta del archivo procesado (para logs).
|
||||
"""
|
||||
try:
|
||||
if self._audio_processor and self._audio_processor.is_loaded:
|
||||
logger.info(
|
||||
"Limpiando GPU después de procesamiento",
|
||||
extra={"file_path": str(filepath)},
|
||||
)
|
||||
self._audio_processor.unload()
|
||||
logger.info(
|
||||
"GPU liberada correctamente",
|
||||
extra={"file_path": str(filepath)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error durante limpieza de GPU (no crítico)",
|
||||
extra={"file_path": str(filepath), "error": str(e)},
|
||||
)
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""
|
||||
Obtiene el estado actual del ProcessManager.
|
||||
|
||||
Returns:
|
||||
Diccionario con estadísticas de procesos.
|
||||
"""
|
||||
states_count = {state.value: 0 for state in ProcessState}
|
||||
for process in self._processes.values():
|
||||
states_count[process.state.value] += 1
|
||||
|
||||
return {
|
||||
"total_processes": len(self._processes),
|
||||
"by_state": states_count,
|
||||
"pending": states_count[ProcessState.PENDING.value],
|
||||
"processing": states_count[ProcessState.DOWNLOADING.value]
|
||||
+ states_count[ProcessState.TRANSCRIBING.value],
|
||||
"completed": states_count[ProcessState.COMPLETED.value],
|
||||
"errors": states_count[ProcessState.ERROR.value],
|
||||
}
|
||||
|
||||
def get_process(self, filepath: Path) -> Optional[ProcessInfo]:
|
||||
"""
|
||||
Obtiene la información de un proceso específico.
|
||||
|
||||
Args:
|
||||
filepath: Ruta al archivo.
|
||||
|
||||
Returns:
|
||||
ProcessInfo si existe, None si no.
|
||||
"""
|
||||
return self._processes.get(str(filepath))
|
||||
|
||||
def get_all_processes(self) -> list[ProcessInfo]:
|
||||
"""
|
||||
Obtiene todos los procesos.
|
||||
|
||||
Returns:
|
||||
Lista de ProcessInfo.
|
||||
"""
|
||||
return list(self._processes.values())
|
||||
|
||||
def clear_completed(self) -> int:
|
||||
"""
|
||||
Limpia procesos completados exitosamente.
|
||||
|
||||
Returns:
|
||||
Número de procesos eliminados.
|
||||
"""
|
||||
keys_to_remove = [
|
||||
k for k, p in self._processes.items()
|
||||
if p.state == ProcessState.COMPLETED
|
||||
]
|
||||
for key in keys_to_remove:
|
||||
del self._processes[key]
|
||||
|
||||
logger.info(
|
||||
"Procesos completados limpiados",
|
||||
extra={"count": len(keys_to_remove)},
|
||||
)
|
||||
return len(keys_to_remove)
|
||||
|
||||
def set_callbacks(
|
||||
self,
|
||||
on_state_change: Optional[NotificationCallback] = None,
|
||||
on_complete: Optional[NotificationCallback] = None,
|
||||
on_error: Optional[NotificationCallback] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Actualiza los callbacks de notificación.
|
||||
|
||||
Args:
|
||||
on_state_change: Callback para cambios de estado.
|
||||
on_complete: Callback para procesos completados.
|
||||
on_error: Callback para errores.
|
||||
"""
|
||||
if on_state_change is not None:
|
||||
self._on_state_change = on_state_change
|
||||
if on_complete is not None:
|
||||
self._on_complete = on_complete
|
||||
if on_error is not None:
|
||||
self._on_error = on_error
|
||||
|
||||
def _update_state(self, process: ProcessInfo, new_state: ProcessState) -> None:
|
||||
"""
|
||||
Actualiza el estado de un proceso.
|
||||
|
||||
Args:
|
||||
process: Proceso a actualizar.
|
||||
new_state: Nuevo estado.
|
||||
"""
|
||||
old_state = process.state
|
||||
process.state = new_state
|
||||
process.updated_at = datetime.now()
|
||||
|
||||
logger.info(
|
||||
f"Cambio de estado: {old_state.value} -> {new_state.value}",
|
||||
extra={
|
||||
"file_path": str(process.file_path),
|
||||
"old_state": old_state.value,
|
||||
"new_state": new_state.value,
|
||||
},
|
||||
)
|
||||
|
||||
if self._on_state_change:
|
||||
try:
|
||||
self._on_state_change(process)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error en callback on_state_change",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
def _notify_complete(self, process: ProcessInfo) -> None:
|
||||
"""Notifica completado."""
|
||||
if self._on_complete:
|
||||
try:
|
||||
self._on_complete(process)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error en callback on_complete",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
def _notify_error(self, process: ProcessInfo) -> None:
|
||||
"""Notifica error."""
|
||||
if self._on_error:
|
||||
try:
|
||||
self._on_error(process)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error en callback on_error",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
def _save_transcription(self, filepath: Path, transcript: str) -> Path:
|
||||
"""
|
||||
Guarda la transcripción en un archivo de texto.
|
||||
|
||||
Args:
|
||||
filepath: Ruta original del archivo de audio.
|
||||
transcript: Texto de la transcripción.
|
||||
|
||||
Returns:
|
||||
Path del archivo guardado.
|
||||
"""
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
transcriptions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_path = transcriptions_dir / f"{filepath.stem}.txt"
|
||||
output_path.write_text(transcript, encoding="utf-8")
|
||||
|
||||
logger.info(
|
||||
"Transcripción guardada",
|
||||
extra={"output_path": str(output_path)},
|
||||
)
|
||||
return output_path
|
||||
|
||||
def generate_summary(self, filepath: Path) -> tuple[Optional[Path], Optional[Path]]:
|
||||
"""
|
||||
Genera un resumen con IA y crea un PDF a partir de la transcripción.
|
||||
|
||||
Args:
|
||||
filepath: Ruta original del archivo de audio.
|
||||
|
||||
Returns:
|
||||
Tupla (md_path, pdf_path) con las rutas generadas o None si falló.
|
||||
"""
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
txt_path = transcriptions_dir / f"{filepath.stem}.txt"
|
||||
|
||||
if not txt_path.exists():
|
||||
logger.warning(
|
||||
"Archivo de transcripción no encontrado, omitiendo resumen",
|
||||
extra={"txt_path": str(txt_path)},
|
||||
)
|
||||
return None, None
|
||||
|
||||
# Notificar inicio de resumen
|
||||
telegram_service.send_summary_start(filepath.name)
|
||||
|
||||
# 1. Leer el .txt de transcripción
|
||||
transcript_text = txt_path.read_text(encoding="utf-8")
|
||||
|
||||
# 2. Llamar a AISummaryService.summarize()
|
||||
summary_text = self._ai_summary_service.summarize(transcript_text)
|
||||
|
||||
# 3. Guardar el resumen como .md en transcriptions/
|
||||
md_path = transcriptions_dir / f"{filepath.stem}_resumen.md"
|
||||
md_path.write_text(summary_text, encoding="utf-8")
|
||||
|
||||
logger.info(
|
||||
"Resumen guardado",
|
||||
extra={"md_path": str(md_path)},
|
||||
)
|
||||
|
||||
# Notificar resumen completado
|
||||
telegram_service.send_summary_complete(filepath.name, has_markdown=True)
|
||||
|
||||
# 4. Llamar a PDFGenerator.markdown_to_pdf()
|
||||
pdf_path = None
|
||||
try:
|
||||
from services.pdf_generator import PDFGenerator
|
||||
|
||||
# Notificar inicio de PDF
|
||||
telegram_service.send_pdf_start(filepath.name)
|
||||
|
||||
pdf_generator = PDFGenerator()
|
||||
pdf_path = md_path.with_suffix(".pdf")
|
||||
pdf_generator.markdown_to_pdf(str(md_path), str(pdf_path))
|
||||
|
||||
logger.info(
|
||||
"PDF generado",
|
||||
extra={"pdf_path": str(pdf_path)},
|
||||
)
|
||||
|
||||
# Notificar PDF completado
|
||||
telegram_service.send_pdf_complete(filepath.name, str(pdf_path))
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"PDFGenerator no disponible, solo se creó el archivo markdown",
|
||||
extra={"md_path": str(md_path)},
|
||||
)
|
||||
|
||||
return md_path, pdf_path
|
||||
|
||||
def _move_audio_to_transcriptions(self, filepath: Path) -> None:
|
||||
"""
|
||||
Mueve el archivo de audio a la carpeta de transcripciones.
|
||||
|
||||
Args:
|
||||
filepath: Ruta del archivo de audio.
|
||||
"""
|
||||
downloads_dir = settings.DOWNLOADS_DIR
|
||||
|
||||
# Solo mover si el archivo está en downloads/
|
||||
if downloads_dir and filepath.parent == downloads_dir:
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
transcriptions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_path = transcriptions_dir / filepath.name
|
||||
|
||||
# Mover el archivo (con manejo de error si ya existe)
|
||||
try:
|
||||
filepath.rename(dest_path)
|
||||
logger.info(
|
||||
"Archivo de audio movido a transcripciones",
|
||||
extra={
|
||||
"from": str(filepath),
|
||||
"to": str(dest_path),
|
||||
},
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# El archivo ya fue movido o no existe, verificar si está en destino
|
||||
if dest_path.exists():
|
||||
logger.info(
|
||||
"Archivo ya estaba en transcripciones",
|
||||
extra={"path": str(dest_path)},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Archivo no encontrado en origen ni destino: {filepath}"
|
||||
)
|
||||
|
||||
def _is_remote_path(self, filepath: Path) -> bool:
|
||||
"""
|
||||
Determina si la ruta es remota.
|
||||
|
||||
Args:
|
||||
filepath: Ruta a verificar.
|
||||
|
||||
Returns:
|
||||
True si es remota, False si es local.
|
||||
"""
|
||||
path_str = str(filepath)
|
||||
# Detectar URLs WebDAV o rutas remotas
|
||||
return path_str.startswith("http://") or path_str.startswith("https://")
|
||||
|
||||
def _download_from_remote(self, process: ProcessInfo) -> None:
|
||||
"""
|
||||
Descarga un archivo desde WebDAV.
|
||||
|
||||
Args:
|
||||
process: Proceso con información del archivo.
|
||||
|
||||
Raises:
|
||||
ProcessManagerError: Si la descarga falla.
|
||||
"""
|
||||
if not self._webdav_service:
|
||||
raise ProcessManagerError("WebDAV no configurado")
|
||||
|
||||
remote_path = str(process.file_path)
|
||||
local_path = Path(process.file_path).name
|
||||
|
||||
logger.info(
|
||||
"Descargando archivo remoto",
|
||||
extra={"remote_path": remote_path, "local_path": str(local_path)},
|
||||
)
|
||||
|
||||
# El archivo ya debería tener la ruta remota
|
||||
# Aquí se manejaría la descarga real
|
||||
# Por ahora solo actualizamos el estado
|
||||
process.updated_at = datetime.now()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representación string del manager."""
|
||||
status = self.get_status()
|
||||
return (
|
||||
f"ProcessManager("
|
||||
f"total={status['total_processes']}, "
|
||||
f"processing={status['processing']}, "
|
||||
f"completed={status['completed']}, "
|
||||
f"errors={status['errors']})"
|
||||
)
|
||||
Reference in New Issue
Block a user