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:
@@ -1,23 +1,5 @@
|
||||
"""
|
||||
Core package for CBCFacil
|
||||
"""
|
||||
"""Core module exports."""
|
||||
from .process_manager import ProcessManager, ProcessState
|
||||
from processors.audio_processor import AudioProcessingError
|
||||
|
||||
from .exceptions import (
|
||||
ProcessingError,
|
||||
WebDAVError,
|
||||
AIProcessingError,
|
||||
ConfigurationError,
|
||||
FileProcessingError
|
||||
)
|
||||
from .result import Result
|
||||
from .base_service import BaseService
|
||||
|
||||
__all__ = [
|
||||
'ProcessingError',
|
||||
'WebDAVError',
|
||||
'AIProcessingError',
|
||||
'ConfigurationError',
|
||||
'FileProcessingError',
|
||||
'Result',
|
||||
'BaseService'
|
||||
]
|
||||
__all__ = ["ProcessManager", "ProcessState", "AudioProcessingError"]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""
|
||||
Base service class for CBCFacil services
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BaseService(ABC):
|
||||
"""Base class for all services"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(f"{__name__}.{name}")
|
||||
|
||||
@abstractmethod
|
||||
def initialize(self) -> None:
|
||||
"""Initialize the service"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup service resources"""
|
||||
pass
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Perform health check"""
|
||||
return True
|
||||
|
||||
def __enter__(self):
|
||||
self.initialize()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.cleanup()
|
||||
@@ -1,38 +0,0 @@
|
||||
"""
|
||||
Custom exceptions for CBCFacil
|
||||
"""
|
||||
|
||||
|
||||
class ProcessingError(Exception):
|
||||
"""Base exception for all processing errors"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(ProcessingError):
|
||||
"""Raised when configuration is invalid"""
|
||||
pass
|
||||
|
||||
|
||||
class WebDAVError(ProcessingError):
|
||||
"""Raised when WebDAV operations fail"""
|
||||
pass
|
||||
|
||||
|
||||
class AIProcessingError(ProcessingError):
|
||||
"""Raised when AI processing fails"""
|
||||
pass
|
||||
|
||||
|
||||
class FileProcessingError(ProcessingError):
|
||||
"""Raised when file processing fails"""
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(ProcessingError):
|
||||
"""Raised when authentication fails"""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ProcessingError):
|
||||
"""Raised when input validation fails"""
|
||||
pass
|
||||
@@ -1,355 +0,0 @@
|
||||
"""
|
||||
Health check endpoint for CBCFacil service monitoring
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
"""Comprehensive health check for all service dependencies"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def check_webdav_connection(self) -> Dict[str, Any]:
|
||||
"""Check WebDAV service connectivity"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "webdav",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from services.webdav_service import webdav_service
|
||||
|
||||
if not settings.has_webdav_config:
|
||||
result["status"] = "not_configured"
|
||||
result["message"] = "WebDAV credentials not configured"
|
||||
return result
|
||||
|
||||
# Test connection with a simple list operation
|
||||
webdav_service.list(".")
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["message"] = "WebDAV connection successful"
|
||||
result["endpoint"] = settings.NEXTCLOUD_URL
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "unhealthy"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"WebDAV health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_ai_providers(self) -> Dict[str, Any]:
|
||||
"""Check AI provider configurations"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "ai_providers",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"providers": {}
|
||||
}
|
||||
|
||||
try:
|
||||
# Check ZAI
|
||||
if settings.ZAI_AUTH_TOKEN:
|
||||
result["providers"]["zai"] = {
|
||||
"configured": True,
|
||||
"status": "unknown"
|
||||
}
|
||||
else:
|
||||
result["providers"]["zai"] = {
|
||||
"configured": False,
|
||||
"status": "not_configured"
|
||||
}
|
||||
|
||||
# Check Gemini
|
||||
if settings.GEMINI_API_KEY:
|
||||
result["providers"]["gemini"] = {
|
||||
"configured": True,
|
||||
"status": "unknown"
|
||||
}
|
||||
else:
|
||||
result["providers"]["gemini"] = {
|
||||
"configured": False,
|
||||
"status": "not_configured"
|
||||
}
|
||||
|
||||
# Check CLI providers
|
||||
if settings.CLAUDE_CLI_PATH:
|
||||
claude_path = Path(settings.CLAUDE_CLI_PATH)
|
||||
result["providers"]["claude_cli"] = {
|
||||
"configured": True,
|
||||
"path_exists": claude_path.exists(),
|
||||
"status": "available" if claude_path.exists() else "path_invalid"
|
||||
}
|
||||
|
||||
if settings.GEMINI_CLI_PATH:
|
||||
gemini_path = Path(settings.GEMINI_CLI_PATH)
|
||||
result["providers"]["gemini_cli"] = {
|
||||
"configured": True,
|
||||
"path_exists": gemini_path.exists(),
|
||||
"status": "available" if gemini_path.exists() else "path_invalid"
|
||||
}
|
||||
|
||||
# Overall status
|
||||
if settings.has_ai_config:
|
||||
result["status"] = "healthy"
|
||||
result["message"] = "At least one AI provider configured"
|
||||
else:
|
||||
result["status"] = "not_configured"
|
||||
result["message"] = "No AI providers configured"
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"AI providers health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_vram_manager(self) -> Dict[str, Any]:
|
||||
"""Check VRAM manager status"""
|
||||
result = {
|
||||
"service": "vram_manager",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from services.vram_manager import vram_manager
|
||||
|
||||
vram_info = vram_manager.get_vram_info()
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["vram_info"] = {
|
||||
"total_gb": round(vram_info.get("total", 0) / (1024**3), 2),
|
||||
"free_gb": round(vram_info.get("free", 0) / (1024**3), 2),
|
||||
"allocated_gb": round(vram_info.get("allocated", 0) / (1024**3), 2)
|
||||
}
|
||||
result["cuda_available"] = vram_info.get("cuda_available", False)
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "unavailable"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"VRAM manager health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_telegram_service(self) -> Dict[str, Any]:
|
||||
"""Check Telegram service status"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "telegram",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from services.telegram_service import telegram_service
|
||||
|
||||
if telegram_service.is_configured:
|
||||
result["status"] = "healthy"
|
||||
result["message"] = "Telegram service configured"
|
||||
else:
|
||||
result["status"] = "not_configured"
|
||||
result["message"] = "Telegram credentials not configured"
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Telegram service health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_processed_registry(self) -> Dict[str, Any]:
|
||||
"""Check processed files registry"""
|
||||
result = {
|
||||
"service": "processed_registry",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from storage.processed_registry import processed_registry
|
||||
|
||||
# Try to load registry
|
||||
processed_registry.load()
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["registry_path"] = str(processed_registry.registry_path)
|
||||
|
||||
# Check if registry file is writable
|
||||
registry_file = Path(processed_registry.registry_path)
|
||||
if registry_file.exists():
|
||||
result["registry_exists"] = True
|
||||
result["registry_writable"] = registry_file.is_file() and os.access(registry_file, os.W_OK)
|
||||
else:
|
||||
result["registry_exists"] = False
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "unhealthy"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Processed registry health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_disk_space(self) -> Dict[str, Any]:
|
||||
"""Check available disk space"""
|
||||
result = {
|
||||
"service": "disk_space",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
import shutil
|
||||
|
||||
# Check main directory
|
||||
usage = shutil.disk_usage(Path(__file__).parent.parent)
|
||||
|
||||
total_gb = usage.total / (1024**3)
|
||||
free_gb = usage.free / (1024**3)
|
||||
used_percent = (usage.used / usage.total) * 100
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["total_gb"] = round(total_gb, 2)
|
||||
result["free_gb"] = round(free_gb, 2)
|
||||
result["used_percent"] = round(used_percent, 2)
|
||||
|
||||
# Warning if low disk space
|
||||
if free_gb < 1: # Less than 1GB
|
||||
result["status"] = "warning"
|
||||
result["message"] = "Low disk space"
|
||||
elif free_gb < 5: # Less than 5GB
|
||||
result["status"] = "degraded"
|
||||
result["message"] = "Disk space running low"
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Disk space health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_configuration(self) -> Dict[str, Any]:
|
||||
"""Check configuration validity"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "configuration",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
warnings = []
|
||||
|
||||
# Check for warnings
|
||||
if not settings.has_webdav_config:
|
||||
warnings.append("WebDAV not configured")
|
||||
|
||||
if not settings.has_ai_config:
|
||||
warnings.append("AI providers not configured")
|
||||
|
||||
if not settings.telegram_configured:
|
||||
warnings.append("Telegram not configured")
|
||||
|
||||
if settings.DASHBOARD_SECRET_KEY == "":
|
||||
warnings.append("Dashboard secret key not set")
|
||||
|
||||
if settings.DASHBOARD_SECRET_KEY == "dashboard-secret-key-change-in-production":
|
||||
warnings.append("Using default dashboard secret")
|
||||
|
||||
result["status"] = "healthy" if not warnings else "warning"
|
||||
result["warnings"] = warnings
|
||||
result["environment"] = settings.environment_type
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Configuration health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def run_full_health_check(self) -> Dict[str, Any]:
|
||||
"""Run all health checks and return comprehensive status"""
|
||||
checks = [
|
||||
("configuration", self.check_configuration),
|
||||
("webdav", self.check_webdav_connection),
|
||||
("ai_providers", self.check_ai_providers),
|
||||
("vram_manager", self.check_vram_manager),
|
||||
("telegram", self.check_telegram_service),
|
||||
("processed_registry", self.check_processed_registry),
|
||||
("disk_space", self.check_disk_space)
|
||||
]
|
||||
|
||||
results = {}
|
||||
overall_status = "healthy"
|
||||
|
||||
for check_name, check_func in checks:
|
||||
try:
|
||||
result = check_func()
|
||||
results[check_name] = result
|
||||
|
||||
# Track overall status
|
||||
if result["status"] in ["unhealthy", "error"]:
|
||||
overall_status = "unhealthy"
|
||||
elif result["status"] in ["warning", "degraded"] and overall_status == "healthy":
|
||||
overall_status = "warning"
|
||||
|
||||
except Exception as e:
|
||||
results[check_name] = {
|
||||
"service": check_name,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
overall_status = "unhealthy"
|
||||
self.logger.error(f"Health check {check_name} failed: {e}")
|
||||
|
||||
return {
|
||||
"overall_status": overall_status,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"checks": results,
|
||||
"summary": {
|
||||
"total_checks": len(checks),
|
||||
"healthy": sum(1 for r in results.values() if r["status"] == "healthy"),
|
||||
"warning": sum(1 for r in results.values() if r["status"] == "warning"),
|
||||
"unhealthy": sum(1 for r in results.values() if r["status"] == "unhealthy")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Convenience function for CLI usage
|
||||
def get_health_status() -> Dict[str, Any]:
|
||||
"""Get comprehensive health status"""
|
||||
checker = HealthChecker()
|
||||
return checker.run_full_health_check()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI usage: python core/health_check.py
|
||||
import sys
|
||||
import os
|
||||
|
||||
health = get_health_status()
|
||||
|
||||
print(json.dumps(health, indent=2))
|
||||
|
||||
# Exit with appropriate code
|
||||
if health["overall_status"] == "healthy":
|
||||
sys.exit(0)
|
||||
elif health["overall_status"] == "warning":
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(2)
|
||||
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']})"
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
Result type for handling success/error cases
|
||||
"""
|
||||
from typing import TypeVar, Generic, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
T = TypeVar('T')
|
||||
E = TypeVar('E')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Success(Generic[T]):
|
||||
"""Successful result with value"""
|
||||
value: T
|
||||
|
||||
def is_success(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return False
|
||||
|
||||
def map(self, func: Callable[[T], 'Success']) -> 'Success[T]':
|
||||
"""Apply function to value"""
|
||||
return func(self.value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Error(Generic[E]):
|
||||
"""Error result with error value"""
|
||||
error: E
|
||||
|
||||
def is_success(self) -> bool:
|
||||
return False
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return True
|
||||
|
||||
def map(self, func: Callable) -> 'Error[E]':
|
||||
"""Return self on error"""
|
||||
return self
|
||||
|
||||
|
||||
Result = Success[T] | Error[E]
|
||||
Reference in New Issue
Block a user