Compare commits
10 Commits
e6a01d08d4
...
ee8fc183be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee8fc183be | ||
|
|
dcf887c510 | ||
|
|
915f827305 | ||
|
|
f9d245a58e | ||
|
|
6058dc642e | ||
|
|
47896fd50a | ||
|
|
f04c1cd548 | ||
|
|
75ef0afcb1 | ||
| 312e303563 | |||
| f7fdb0b622 |
@@ -40,6 +40,14 @@ GEMINI_CLI_PATH=/path/to/gemini # or leave empty
|
|||||||
TELEGRAM_TOKEN=your_telegram_bot_token
|
TELEGRAM_TOKEN=your_telegram_bot_token
|
||||||
TELEGRAM_CHAT_ID=your_telegram_chat_id
|
TELEGRAM_CHAT_ID=your_telegram_chat_id
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Notion Integration (Optional - for automatic PDF uploads)
|
||||||
|
# =============================================================================
|
||||||
|
# Get your token from: https://developers.notion.com/docs/create-a-notion-integration
|
||||||
|
NOTION_API=ntn_YOUR_NOTION_INTEGRATION_TOKEN_HERE
|
||||||
|
# Get your database ID from the database URL in Notion
|
||||||
|
NOTION_DATABASE_ID=your_database_id_here
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Dashboard Configuration (Required for production)
|
# Dashboard Configuration (Required for production)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -9,6 +9,7 @@ __pycache__/
|
|||||||
|
|
||||||
# Application-generated data
|
# Application-generated data
|
||||||
downloads/
|
downloads/
|
||||||
|
transcriptions/
|
||||||
resumenes/
|
resumenes/
|
||||||
resumenes_docx/
|
resumenes_docx/
|
||||||
processed_files.txt
|
processed_files.txt
|
||||||
@@ -71,3 +72,17 @@ old/
|
|||||||
imperio/
|
imperio/
|
||||||
check_models.py
|
check_models.py
|
||||||
compare_configs.py
|
compare_configs.py
|
||||||
|
|
||||||
|
# LaTeX auxiliary files
|
||||||
|
*.aux
|
||||||
|
*.toc
|
||||||
|
*.out
|
||||||
|
*.synctex.gz
|
||||||
|
*.fls
|
||||||
|
*.fdb_latexmk
|
||||||
|
|
||||||
|
# Generated PDFs (keep source .tex files)
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
# macOS specific
|
||||||
|
mac/
|
||||||
|
|||||||
501
ARCHITECTURE.md
501
ARCHITECTURE.md
@@ -1,501 +0,0 @@
|
|||||||
# Arquitectura CBCFacil v9
|
|
||||||
|
|
||||||
## Resumen Ejecutivo
|
|
||||||
|
|
||||||
CBCFacil es un servicio de IA modular para procesamiento de documentos (audio, PDF, texto) con integracion a Nextcloud. Este documento describe la arquitectura actual, patrones de diseno utilizados y guia de extension del sistema.
|
|
||||||
|
|
||||||
## Evucion Arquitectonica
|
|
||||||
|
|
||||||
### Problema Original (v7)
|
|
||||||
|
|
||||||
El proyecto sufria de un archivo monolitico de 3167 lineas (`main.py`) que contenia todas las responsabilidades en un solo archivo.
|
|
||||||
|
|
||||||
### Solucion Actual (v9)
|
|
||||||
|
|
||||||
Arquitectura modular con separacion clara de responsabilidades en capas independientes.
|
|
||||||
|
|
||||||
```
|
|
||||||
FLUJO DE DATOS
|
|
||||||
=============
|
|
||||||
|
|
||||||
1. MONITOREO 2. DESCARGA 3. PROCESAMIENTO
|
|
||||||
+---------------+ +---------------+ +---------------+
|
|
||||||
| Nextcloud |-------->| Downloads/ |------>| Processors |
|
|
||||||
| (WebDAV) | | Local | | (Audio/PDF) |
|
|
||||||
+---------------+ +---------------+ +---------------+
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
+---------------+ +---------------+
|
|
||||||
| WebDAV | | AI Services |
|
|
||||||
| Service | | (Claude/ |
|
|
||||||
+---------------+ | Gemini) |
|
|
||||||
+---------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
4. GENERACION 5. REGISTRO 6. NOTIFICACION
|
|
||||||
+---------------+ +---------------+ +---------------+
|
|
||||||
| Document |-------->| Processed |------>| Telegram |
|
|
||||||
| Generators | | Registry | | Service |
|
|
||||||
+---------------+ +---------------+ +---------------+
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
+---------------+ +---------------+
|
|
||||||
| Nextcloud | | Dashboard |
|
|
||||||
| (Upload) | | (Flask) |
|
|
||||||
+---------------+ +---------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
## Estructura de Directorios
|
|
||||||
|
|
||||||
```
|
|
||||||
cbcfacil/
|
|
||||||
├── main.py # Orquestador principal (149 lineas)
|
|
||||||
├── run.py # Script de ejecucion alternativo
|
|
||||||
├── config/ # Configuracion centralizada
|
|
||||||
│ ├── __init__.py # Exports: settings, validate_environment
|
|
||||||
│ ├── settings.py # Configuracion desde variables de entorno
|
|
||||||
│ └── validators.py # Validadores de configuracion
|
|
||||||
├── core/ # Nucleo compartido
|
|
||||||
│ ├── __init__.py # Exports: excepciones, Result
|
|
||||||
│ ├── exceptions.py # Excepciones personalizadas
|
|
||||||
│ ├── result.py # Patron Result/Error handling
|
|
||||||
│ └── base_service.py # Clase base BaseService
|
|
||||||
├── services/ # Servicios externos
|
|
||||||
│ ├── __init__.py # Exports de servicios
|
|
||||||
│ ├── webdav_service.py # WebDAV/Nextcloud operaciones
|
|
||||||
│ ├── vram_manager.py # GPU memory management
|
|
||||||
│ ├── telegram_service.py # Telegram notificaciones
|
|
||||||
│ ├── metrics_collector.py # Metricas y estadisticas
|
|
||||||
│ ├── ai_service.py # Servicio AI unificado
|
|
||||||
│ └── ai/ # AI Providers
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── base_provider.py # Interfaz BaseProvider
|
|
||||||
│ ├── claude_provider.py # Claude (Z.ai) implementation
|
|
||||||
│ ├── gemini_provider.py # Gemini API/CLI implementation
|
|
||||||
│ └── provider_factory.py # Factory para proveedores
|
|
||||||
├── processors/ # Procesadores de archivos
|
|
||||||
│ ├── __init__.py # Exports de procesadores
|
|
||||||
│ ├── base_processor.py # Clase base FileProcessor
|
|
||||||
│ ├── audio_processor.py # Whisper transcription
|
|
||||||
│ ├── pdf_processor.py # PDF OCR processing
|
|
||||||
│ └── text_processor.py # Text summarization
|
|
||||||
├── document/ # Generacion de documentos
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── generators.py # DOCX/PDF/Markdown generation
|
|
||||||
├── storage/ # Persistencia y cache
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── processed_registry.py # Registro de archivos procesados
|
|
||||||
├── api/ # API REST
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── routes.py # Flask routes y endpoints
|
|
||||||
├── tests/ # Tests unitarios e integracion
|
|
||||||
│ ├── conftest.py # Fixtures pytest
|
|
||||||
│ ├── test_config.py # Tests de configuracion
|
|
||||||
│ ├── test_storage.py # Tests de almacenamiento
|
|
||||||
│ ├── test_webdav.py # Tests de WebDAV
|
|
||||||
│ ├── test_processors.py # Tests de procesadores
|
|
||||||
│ ├── test_ai_providers.py # Tests de AI providers
|
|
||||||
│ ├── test_vram_manager.py # Tests de VRAM manager
|
|
||||||
│ └── test_main_integration.py # Tests de integracion main
|
|
||||||
├── docs/ # Documentacion
|
|
||||||
│ ├── archive/ # Documentacion historica
|
|
||||||
│ ├── SETUP.md # Guia de configuracion
|
|
||||||
│ ├── TESTING.md # Guia de testing
|
|
||||||
│ └── DEPLOYMENT.md # Guia de despliegue
|
|
||||||
├── requirements.txt # Dependencias produccion
|
|
||||||
├── requirements-dev.txt # Dependencias desarrollo
|
|
||||||
├── .env.example # Template de configuracion
|
|
||||||
├── .env.secrets # Configuracion local (no versionar)
|
|
||||||
└── Dockerfile # Container Docker
|
|
||||||
```
|
|
||||||
|
|
||||||
## Componentes Principales
|
|
||||||
|
|
||||||
### 1. Servicios (services/)
|
|
||||||
|
|
||||||
#### WebDAVService
|
|
||||||
|
|
||||||
**Archivo**: `services/webdav_service.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Conexion y operaciones con Nextcloud via WebDAV
|
|
||||||
- Download/upload de archivos
|
|
||||||
- Listado y creacion de directorios remotos
|
|
||||||
- Manejo de errores con reintentos configurables
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WebDAVService:
|
|
||||||
def initialize(self) -> None: ...
|
|
||||||
def list(self, remote_path: str) -> List[str]: ...
|
|
||||||
def download(self, remote_path: str, local_path: Path) -> None: ...
|
|
||||||
def upload(self, local_path: Path, remote_path: str) -> None: ...
|
|
||||||
def mkdir(self, remote_path: str) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### VRAMManager
|
|
||||||
|
|
||||||
**Archivo**: `services/vram_manager.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Gestion de memoria GPU
|
|
||||||
- Carga/descarga de modelos (Whisper, OCR, TrOCR)
|
|
||||||
- Limpieza automatica de VRAM ociosa
|
|
||||||
- Fallback a CPU cuando GPU no disponible
|
|
||||||
|
|
||||||
```python
|
|
||||||
class VRAMManager:
|
|
||||||
def initialize(self) -> None: ...
|
|
||||||
def cleanup(self) -> None: ...
|
|
||||||
def should_cleanup(self) -> bool: ...
|
|
||||||
def lazy_cleanup(self) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### TelegramService
|
|
||||||
|
|
||||||
**Archivo**: `services/telegram_service.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Envio de notificaciones a Telegram
|
|
||||||
- Throttling de errores para evitar spam
|
|
||||||
- Notificaciones de inicio/parada del servicio
|
|
||||||
|
|
||||||
```python
|
|
||||||
class TelegramService:
|
|
||||||
def configure(self, token: str, chat_id: str) -> None: ...
|
|
||||||
def send_message(self, message: str) -> None: ...
|
|
||||||
def send_error_notification(self, context: str, error: str) -> None: ...
|
|
||||||
def send_start_notification(self) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Procesadores (processors/)
|
|
||||||
|
|
||||||
#### AudioProcessor
|
|
||||||
|
|
||||||
**Archivo**: `processors/audio_processor.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Transcripcion de audio usando Whisper
|
|
||||||
- Modelo: medium (optimizado para espanol)
|
|
||||||
- Soporte GPU/CPU automatico
|
|
||||||
- Post-procesamiento de texto transcrito
|
|
||||||
|
|
||||||
#### PDFProcessor
|
|
||||||
|
|
||||||
**Archivo**: `processors/pdf_processor.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Extraccion de texto de PDFs
|
|
||||||
- OCR con EasyOCR + Tesseract + TrOCR en paralelo
|
|
||||||
- Correccion de texto con IA
|
|
||||||
- Generacion de documentos DOCX
|
|
||||||
|
|
||||||
#### TextProcessor
|
|
||||||
|
|
||||||
**Archivo**: `processors/text_processor.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Resumenes usando IA (Claude/Gemini)
|
|
||||||
- Clasificacion de contenido
|
|
||||||
- Generacion de quizzes opcionales
|
|
||||||
|
|
||||||
### 3. AI Services (services/ai/)
|
|
||||||
|
|
||||||
#### ProviderFactory
|
|
||||||
|
|
||||||
**Archivo**: `services/ai/provider_factory.py`
|
|
||||||
|
|
||||||
Patron Factory para seleccion dinamica de proveedor de IA:
|
|
||||||
```python
|
|
||||||
class ProviderFactory:
|
|
||||||
def get_provider(self, provider_type: str = "auto") -> BaseProvider: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Proveedores disponibles:
|
|
||||||
- `claude`: Claude via Z.ai API
|
|
||||||
- `gemini`: Google Gemini API
|
|
||||||
- `gemini_cli`: Gemini CLI local
|
|
||||||
- `auto`: Seleccion automatica basada en disponibilidad
|
|
||||||
|
|
||||||
### 4. Document Generation (document/)
|
|
||||||
|
|
||||||
#### DocumentGenerator
|
|
||||||
|
|
||||||
**Archivo**: `document/generators.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Creacion de documentos DOCX
|
|
||||||
- Conversion a PDF
|
|
||||||
- Formateo Markdown
|
|
||||||
- Plantillas de documentos
|
|
||||||
|
|
||||||
### 5. Storage (storage/)
|
|
||||||
|
|
||||||
#### ProcessedRegistry
|
|
||||||
|
|
||||||
**Archivo**: `storage/processed_registry.py`
|
|
||||||
|
|
||||||
Responsabilidades:
|
|
||||||
- Registro persistente de archivos procesados
|
|
||||||
- Cache en memoria con TTL
|
|
||||||
- File locking para thread-safety
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ProcessedRegistry:
|
|
||||||
def initialize(self) -> None: ...
|
|
||||||
def load(self) -> Set[str]: ...
|
|
||||||
def save(self, file_path: str) -> None: ...
|
|
||||||
def is_processed(self, file_path: str) -> bool: ...
|
|
||||||
def mark_for_reprocess(self, file_path: str) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. API (api/)
|
|
||||||
|
|
||||||
#### Flask Routes
|
|
||||||
|
|
||||||
**Archivo**: `api/routes.py`
|
|
||||||
|
|
||||||
Endpoints REST disponibles:
|
|
||||||
- `GET /api/files` - Listado de archivos
|
|
||||||
- `POST /api/reprocess` - Reprocesar archivo
|
|
||||||
- `POST /api/mark-unprocessed` - Resetear estado
|
|
||||||
- `GET /api/refresh` - Sincronizar con Nextcloud
|
|
||||||
- `GET /health` - Health check
|
|
||||||
|
|
||||||
## Patrones de Diseno Utilizados
|
|
||||||
|
|
||||||
### 1. Repository Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
# storage/processed_registry.py
|
|
||||||
class ProcessedRegistry:
|
|
||||||
def save(self, file_path: str) -> None: ...
|
|
||||||
def load(self) -> Set[str]: ...
|
|
||||||
def is_processed(self, file_path: str) -> bool: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Factory Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
# services/ai/provider_factory.py
|
|
||||||
class ProviderFactory:
|
|
||||||
def get_provider(self, provider_type: str = "auto") -> BaseProvider: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Strategy Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
# services/vram_manager.py
|
|
||||||
class VRAMManager:
|
|
||||||
def cleanup(self) -> None: ...
|
|
||||||
def should_cleanup(self) -> bool: ...
|
|
||||||
def lazy_cleanup(self) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Service Layer Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
# services/webdav_service.py
|
|
||||||
class WebDAVService:
|
|
||||||
def list(self, remote_path: str) -> List[str]: ...
|
|
||||||
def download(self, remote_path: str, local_path: Path) -> None: ...
|
|
||||||
def upload(self, local_path: Path, remote_path: str) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Singleton Pattern
|
|
||||||
|
|
||||||
Servicios implementados como singletons para compartir estado:
|
|
||||||
```python
|
|
||||||
# services/webdav_service.py
|
|
||||||
webdav_service = WebDAVService()
|
|
||||||
|
|
||||||
# services/vram_manager.py
|
|
||||||
vram_manager = VRAMManager()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Result Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
# core/result.py
|
|
||||||
class Result:
|
|
||||||
@staticmethod
|
|
||||||
def success(value): ...
|
|
||||||
@staticmethod
|
|
||||||
def failure(error): ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Decisiones Arquitectonicas (ADR)
|
|
||||||
|
|
||||||
### ADR-001: Arquitectura Modular
|
|
||||||
|
|
||||||
**Decision**: Separar el monolito en modulos independientes.
|
|
||||||
|
|
||||||
**Contexto**: El archivo main.py de 3167 lineas era dificil de mantener y testar.
|
|
||||||
|
|
||||||
**Decision**: Separar en capas: config/, core/, services/, processors/, document/, storage/, api/.
|
|
||||||
|
|
||||||
**Consecuencias**:
|
|
||||||
- Positivo: Codigo mas mantenible y testeable
|
|
||||||
- Positivo: Reutilizacion de componentes
|
|
||||||
- Negativo: Mayor complejidad inicial
|
|
||||||
|
|
||||||
### ADR-002: Configuracion Centralizada
|
|
||||||
|
|
||||||
**Decision**: Usar clase Settings con variables de entorno.
|
|
||||||
|
|
||||||
**Contexto**: Credenciales hardcodeadas representan riesgo de seguridad.
|
|
||||||
|
|
||||||
**Decision**: Todas las configuraciones via variables de entorno con .env.secrets.
|
|
||||||
|
|
||||||
**Consecuencias**:
|
|
||||||
- Positivo: Seguridad mejorada
|
|
||||||
- Positivo: Facil despliegue en diferentes entornos
|
|
||||||
- Negativo: Requiere documentacion de variables
|
|
||||||
|
|
||||||
### ADR-003: GPU-First con CPU Fallback
|
|
||||||
|
|
||||||
**Decision**: Optimizar para GPU pero soportar CPU.
|
|
||||||
|
|
||||||
**Contexto**: No todos los usuarios tienen GPU disponible.
|
|
||||||
|
|
||||||
**Decision**: VRAMManager con lazy loading y cleanup automatico.
|
|
||||||
|
|
||||||
**Consecuencias**:
|
|
||||||
- Positivo: Performance optimo en GPU
|
|
||||||
- Positivo: Funciona sin GPU
|
|
||||||
- Negativo: Complejidad adicional en gestion de memoria
|
|
||||||
|
|
||||||
### ADR-004: Factory para AI Providers
|
|
||||||
|
|
||||||
**Decision**: Abstraer proveedores de IA detras de interfaz comun.
|
|
||||||
|
|
||||||
**Contexto**: Multiples proveedores (Claude, Gemini) con diferentes APIs.
|
|
||||||
|
|
||||||
**Decision**: BaseProvider con implementaciones concretas y ProviderFactory.
|
|
||||||
|
|
||||||
**Consecuencias**:
|
|
||||||
- Positivo: Facilidad para agregar nuevos proveedores
|
|
||||||
- Positivo: Fallback entre proveedores
|
|
||||||
- Negativo: Sobrecarga de abstraccion
|
|
||||||
|
|
||||||
## Guia de Extension del Sistema
|
|
||||||
|
|
||||||
### Agregar Nuevo Procesador
|
|
||||||
|
|
||||||
1. Crear archivo en `processors/`:
|
|
||||||
```python
|
|
||||||
from processors.base_processor import FileProcessor
|
|
||||||
|
|
||||||
class NuevoProcessor(FileProcessor):
|
|
||||||
def process(self, file_path: str) -> None:
|
|
||||||
# Implementar procesamiento
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Registrar en `processors/__init__.py`:
|
|
||||||
```python
|
|
||||||
from processors.nuevo_processor import NuevoProcessor
|
|
||||||
|
|
||||||
__all__ = ['NuevoProcessor', ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Integrar en `main.py`:
|
|
||||||
```python
|
|
||||||
from processors.nuevo_processor import NuevoProcessor
|
|
||||||
|
|
||||||
nuevo_processor = NuevoProcessor()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agregar Nuevo AI Provider
|
|
||||||
|
|
||||||
1. Crear clase en `services/ai/`:
|
|
||||||
```python
|
|
||||||
from services.ai.base_provider import BaseProvider
|
|
||||||
|
|
||||||
class NuevoProvider(BaseProvider):
|
|
||||||
def summarize(self, text: str) -> str:
|
|
||||||
# Implementar
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Registrar en `provider_factory.py`:
|
|
||||||
```python
|
|
||||||
PROVIDERS = {
|
|
||||||
'nuevo': NuevoProvider,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Usar:
|
|
||||||
```python
|
|
||||||
provider = factory.get_provider('nuevo')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agregar Nuevo Servicio
|
|
||||||
|
|
||||||
1. Crear archivo en `services/`:
|
|
||||||
```python
|
|
||||||
from core.base_service import BaseService
|
|
||||||
|
|
||||||
class NuevoService(BaseService):
|
|
||||||
def initialize(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
nuevo_service = NuevoService()
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Inicializar en `main.py`:
|
|
||||||
```python
|
|
||||||
from services.nuevo_service import nuevo_service
|
|
||||||
|
|
||||||
nuevo_service.initialize()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agregar Nuevo Endpoint API
|
|
||||||
|
|
||||||
1. Editar `api/routes.py`:
|
|
||||||
```python
|
|
||||||
@app.route('/api/nuevo', methods=['GET'])
|
|
||||||
def nuevo_endpoint():
|
|
||||||
return {'status': 'ok'}, 200
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuracion Detallada
|
|
||||||
|
|
||||||
### Variables de Entorno Principales
|
|
||||||
|
|
||||||
| Variable | Requerido | Default | Descripcion |
|
|
||||||
|----------|-----------|---------|-------------|
|
|
||||||
| NEXTCLOUD_URL | Si | - | URL de Nextcloud WebDAV |
|
|
||||||
| NEXTCLOUD_USER | Si | - | Usuario Nextcloud |
|
|
||||||
| NEXTCLOUD_PASSWORD | Si | - | Contrasena Nextcloud |
|
|
||||||
| ANTHROPIC_AUTH_TOKEN | No | - | Token Claude/Z.ai |
|
|
||||||
| GEMINI_API_KEY | No | - | API Key Gemini |
|
|
||||||
| TELEGRAM_TOKEN | No | - | Token Bot Telegram |
|
|
||||||
| TELEGRAM_CHAT_ID | No | - | Chat ID Telegram |
|
|
||||||
| CUDA_VISIBLE_DEVICES | No | "all" | GPU a usar |
|
|
||||||
| POLL_INTERVAL | No | 5 | Segundos entre polls |
|
|
||||||
| LOG_LEVEL | No | "INFO" | Nivel de logging |
|
|
||||||
|
|
||||||
## Metricas y Benchmarks
|
|
||||||
|
|
||||||
| Metrica | Valor |
|
|
||||||
|---------|-------|
|
|
||||||
| Lineas main.py | 149 (antes 3167) |
|
|
||||||
| Modulos independientes | 8+ |
|
|
||||||
| Cobertura tests | ~60%+ |
|
|
||||||
| Tiempo inicio | 5-10s |
|
|
||||||
| Transcripcion Whisper | ~1x tiempo audio (GPU) |
|
|
||||||
| OCR PDF | 0.5-2s/pagina |
|
|
||||||
|
|
||||||
## Beneficios de la Arquitectura
|
|
||||||
|
|
||||||
1. **Mantenibilidad**: Cada responsabilidad en su propio modulo
|
|
||||||
2. **Testabilidad**: Servicios independientes y testeables
|
|
||||||
3. **Escalabilidad**: Facil agregar nuevos procesadores/servicios
|
|
||||||
4. **Reutilizacion**: Componentes desacoplados
|
|
||||||
5. **Legibilidad**: Codigo organizado y documentado
|
|
||||||
6. **Seguridad**: Configuracion centralizada sin hardcoding
|
|
||||||
|
|
||||||
## Licencia
|
|
||||||
|
|
||||||
MIT License - Ver LICENSE para detalles.
|
|
||||||
60
Dockerfile
60
Dockerfile
@@ -1,60 +0,0 @@
|
|||||||
# Usar una imagen base de NVIDIA con CUDA 12.1.1 y Python 3.10
|
|
||||||
FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04
|
|
||||||
|
|
||||||
# Evitar que los cuadros de diálogo de apt se bloqueen
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
# Instalar Python, pip y dependencias del sistema
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
python3.10 \
|
|
||||||
python3-pip \
|
|
||||||
ffmpeg \
|
|
||||||
poppler-utils \
|
|
||||||
tesseract-ocr \
|
|
||||||
tesseract-ocr-spa \
|
|
||||||
curl \
|
|
||||||
libgl1 \
|
|
||||||
libglib2.0-0 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Instalar Node.js 20 usando NodeSource repository
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|
||||||
apt-get install -y nodejs && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Crear un enlace simbólico para que python3 -> python
|
|
||||||
RUN ln -s /usr/bin/python3 /usr/bin/python
|
|
||||||
|
|
||||||
# Establecer el directorio de trabajo
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copiar requerimientos e instalar dependencias de Python
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN python3 -m pip install --no-cache-dir --upgrade pip && \
|
|
||||||
python3 -m pip install --no-cache-dir \
|
|
||||||
torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 && \
|
|
||||||
python3 -m pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Instalar Claude CLI
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
|
||||||
|
|
||||||
# Instalar Gemini CLI como root
|
|
||||||
RUN npm install -g @google/gemini-cli
|
|
||||||
|
|
||||||
# Crear usuario sin privilegios para ejecutar la app (evita bloqueos del CLI)
|
|
||||||
ARG APP_UID=1000
|
|
||||||
ARG APP_GID=1000
|
|
||||||
RUN groupadd --gid ${APP_GID} appgroup \
|
|
||||||
&& useradd --uid ${APP_UID} --gid ${APP_GID} --create-home appuser
|
|
||||||
|
|
||||||
# Copiar todo el código de la aplicación al contenedor
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Dar acceso al usuario no root
|
|
||||||
RUN chown -R appuser:appgroup /app
|
|
||||||
|
|
||||||
# Ejecutar como usuario sin privilegios (requerido por Claude CLI)
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
# Comando por defecto para iniciar el servicio principal unificado
|
|
||||||
CMD ["python3", "main.py"]
|
|
||||||
206
README.md
206
README.md
@@ -1,206 +0,0 @@
|
|||||||
# CBCFacil v9
|
|
||||||
|
|
||||||
Servicio de IA unificado para procesamiento inteligente de documentos (audio, PDF, texto) con integracion a Nextcloud.
|
|
||||||
|
|
||||||
## Descripcion General
|
|
||||||
|
|
||||||
CBCFacil monitoriza automaticamente tu servidor Nextcloud, descarga archivos multimedia, los transcribe/resume utilizando modelos de IA (Whisper, Claude, Gemini) y genera documentos formateados para su descarga.
|
|
||||||
|
|
||||||
## Arquitectura
|
|
||||||
|
|
||||||
```
|
|
||||||
+----------------+ +--------------+ +-----------------+ +------------------+
|
|
||||||
| Nextcloud |---->| Procesador |---->| IA Services |---->| Dashboard/API |
|
|
||||||
| (WebDAV) | | (Audio/PDF) | | (Claude/Gemini)| | (Flask) |
|
|
||||||
+----------------+ +--------------+ +-----------------+ +------------------+
|
|
||||||
| | |
|
|
||||||
v v v
|
|
||||||
+------------+ +------------+ +------------+
|
|
||||||
| Whisper | | Gemini | | Telegram |
|
|
||||||
| (GPU) | | API/CLI | | (notify) |
|
|
||||||
+------------+ +------------+ +------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
## Estructura del Proyecto
|
|
||||||
|
|
||||||
```
|
|
||||||
cbcfacil/
|
|
||||||
├── main.py # Punto de entrada del servicio
|
|
||||||
├── run.py # Script de ejecucion alternativo
|
|
||||||
├── config/ # Configuracion centralizada
|
|
||||||
│ ├── settings.py # Variables de entorno y settings
|
|
||||||
│ └── validators.py # Validadores de configuracion
|
|
||||||
├── core/ # Nucleo del sistema
|
|
||||||
│ ├── exceptions.py # Excepciones personalizadas
|
|
||||||
│ ├── result.py # Patron Result
|
|
||||||
│ └── base_service.py # Clase base para servicios
|
|
||||||
├── services/ # Servicios externos
|
|
||||||
│ ├── webdav_service.py # Operacones WebDAV/Nextcloud
|
|
||||||
│ ├── vram_manager.py # Gestion de memoria GPU
|
|
||||||
│ ├── telegram_service.py # Notificaciones Telegram
|
|
||||||
│ └── ai/ # Proveedores de IA
|
|
||||||
│ ├── base_provider.py # Interfaz base
|
|
||||||
│ ├── claude_provider.py # Claude (Z.ai)
|
|
||||||
│ ├── gemini_provider.py # Gemini API/CLI
|
|
||||||
│ └── provider_factory.py # Factory de proveedores
|
|
||||||
├── processors/ # Procesadores de archivos
|
|
||||||
│ ├── audio_processor.py # Transcripcion Whisper
|
|
||||||
│ ├── pdf_processor.py # OCR y extraccion PDF
|
|
||||||
│ └── text_processor.py # Resumenes y clasificacion
|
|
||||||
├── document/ # Generacion de documentos
|
|
||||||
│ └── generators.py # DOCX, PDF, Markdown
|
|
||||||
├── storage/ # Persistencia
|
|
||||||
│ └── processed_registry.py # Registro de archivos procesados
|
|
||||||
├── api/ # API REST
|
|
||||||
│ └── routes.py # Endpoints Flask
|
|
||||||
├── tests/ # Tests unitarios e integracion
|
|
||||||
├── docs/ # Documentacion
|
|
||||||
│ ├── archive/ # Documentacion historica
|
|
||||||
│ ├── SETUP.md # Guia de configuracion
|
|
||||||
│ ├── TESTING.md # Guia de testing
|
|
||||||
│ └── DEPLOYMENT.md # Guia de despliegue
|
|
||||||
├── requirements.txt # Dependencias Python
|
|
||||||
├── requirements-dev.txt # Dependencias desarrollo
|
|
||||||
├── .env.secrets # Configuracion local (no versionar)
|
|
||||||
└── Dockerfile # Container Docker
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requisitos
|
|
||||||
|
|
||||||
- Python 3.10+
|
|
||||||
- NVIDIA GPU con drivers CUDA 12.1+ (opcional, soporta CPU fallback)
|
|
||||||
- Nextcloud accesible via WebDAV
|
|
||||||
- Claves API para servicios de IA (opcional)
|
|
||||||
|
|
||||||
## Instalacion Rapida
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clonar y entrar al directorio
|
|
||||||
git clone <repo_url>
|
|
||||||
cd cbcfacil
|
|
||||||
|
|
||||||
# Crear entorno virtual
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Instalar dependencias
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Configurar variables de entorno
|
|
||||||
cp .env.example .env.secrets
|
|
||||||
# Editar .env.secrets con tus credenciales
|
|
||||||
|
|
||||||
# Ejecutar el servicio
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuracion
|
|
||||||
|
|
||||||
### Variables de Entorno (.env.secrets)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Nextcloud/WebDAV (requerido para sincronizacion)
|
|
||||||
NEXTCLOUD_URL=https://tu-nextcloud.com/remote.php/webdav
|
|
||||||
NEXTCLOUD_USER=usuario
|
|
||||||
NEXTCLOUD_PASSWORD=contrasena
|
|
||||||
|
|
||||||
# AI Providers (requerido para resúmenes)
|
|
||||||
ANTHROPIC_AUTH_TOKEN=sk-ant-...
|
|
||||||
GEMINI_API_KEY=AIza...
|
|
||||||
|
|
||||||
# Telegram (opcional)
|
|
||||||
TELEGRAM_TOKEN=bot_token
|
|
||||||
TELEGRAM_CHAT_ID=chat_id
|
|
||||||
|
|
||||||
# GPU (opcional)
|
|
||||||
CUDA_VISIBLE_DEVICES=0
|
|
||||||
```
|
|
||||||
|
|
||||||
Ver `.env.example` para todas las variables disponibles.
|
|
||||||
|
|
||||||
## Uso
|
|
||||||
|
|
||||||
### Servicio Completo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar con monitoring y dashboard
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Comandos CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Transcribir audio
|
|
||||||
python main.py whisper <archivo_audio> <output_dir>
|
|
||||||
|
|
||||||
# Procesar PDF
|
|
||||||
python main.py pdf <archivo_pdf> <output_dir>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dashboard
|
|
||||||
|
|
||||||
El dashboard se expone en `http://localhost:5000` e incluye:
|
|
||||||
- Vista de archivos procesados/pendientes
|
|
||||||
- API REST para integraciones
|
|
||||||
- Endpoints de salud
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar todos los tests
|
|
||||||
pytest tests/
|
|
||||||
|
|
||||||
# Tests con coverage
|
|
||||||
pytest tests/ --cov=cbcfacil --cov-report=term-missing
|
|
||||||
|
|
||||||
# Tests especificos
|
|
||||||
pytest tests/test_config.py -v
|
|
||||||
pytest tests/test_storage.py -v
|
|
||||||
pytest tests/test_processors.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Ver `docs/TESTING.md` para guia completa.
|
|
||||||
|
|
||||||
## Despliegue
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
docker logs -f cbcfacil
|
|
||||||
```
|
|
||||||
|
|
||||||
### Produccion
|
|
||||||
|
|
||||||
Ver `docs/DEPLOYMENT.md` para guia completa de despliegue.
|
|
||||||
|
|
||||||
## Metricas de Performance
|
|
||||||
|
|
||||||
| Componente | Metrica |
|
|
||||||
|------------|---------|
|
|
||||||
| main.py | 149 lineas (antes 3167) |
|
|
||||||
| Cobertura tests | ~60%+ |
|
|
||||||
| Tiempo inicio | ~5-10s |
|
|
||||||
| Transcripcion Whisper | ~1x tiempo audio (GPU) |
|
|
||||||
| Resumen Claude | ~2-5s por documento |
|
|
||||||
| OCR PDF | Depende de paginas |
|
|
||||||
|
|
||||||
## Contribucion
|
|
||||||
|
|
||||||
1. Fork el repositorio
|
|
||||||
2. Crear branch feature (`git checkout -b feature/nueva-funcionalidad`)
|
|
||||||
3. Commit cambios (`git commit -am 'Add nueva funcionalidad'`)
|
|
||||||
4. Push al branch (`git push origin feature/nueva-funcionalidad`)
|
|
||||||
5. Crear Pull Request
|
|
||||||
|
|
||||||
## Documentacion
|
|
||||||
|
|
||||||
- `docs/SETUP.md` - Guia de configuracion inicial
|
|
||||||
- `docs/TESTING.md` - Guia de testing
|
|
||||||
- `docs/DEPLOYMENT.md` - Guia de despliegue
|
|
||||||
- `ARCHITECTURE.md` - Documentacion arquitectonica
|
|
||||||
- `CHANGELOG.md` - Historial de cambios
|
|
||||||
|
|
||||||
## Licencia
|
|
||||||
|
|
||||||
MIT License - Ver LICENSE para detalles.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# 🚀 ROCm Setup para AMD GPU
|
|
||||||
|
|
||||||
## ✅ Estado Actual del Sistema
|
|
||||||
|
|
||||||
**GPU**: AMD Radeon RX 6800 XT
|
|
||||||
**ROCm**: 6.0
|
|
||||||
**PyTorch**: 2.5.0+rocm6.0
|
|
||||||
|
|
||||||
## 📋 Comandos Esenciales
|
|
||||||
|
|
||||||
### Verificar GPU
|
|
||||||
```bash
|
|
||||||
# Información básica de la GPU
|
|
||||||
lspci | grep -i vga
|
|
||||||
|
|
||||||
# Estado en tiempo real de ROCm
|
|
||||||
rocm-smi
|
|
||||||
|
|
||||||
# Información detallada del sistema
|
|
||||||
rocminfo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables de Entorno Críticas
|
|
||||||
```bash
|
|
||||||
# CRÍTICO para gfx1030 (RX 6000 series)
|
|
||||||
export HSA_OVERRIDE_GFX_VERSION=10.3.0
|
|
||||||
|
|
||||||
# Agregar al ~/.bashrc o ~/.zshrc
|
|
||||||
echo 'export HSA_OVERRIDE_GFX_VERSION=10.3.0' >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verificar PyTorch con ROCm
|
|
||||||
```bash
|
|
||||||
# Test básico de PyTorch
|
|
||||||
python3 -c "
|
|
||||||
import torch
|
|
||||||
print(f'PyTorch: {torch.__version__}')
|
|
||||||
print(f'ROCm disponible: {torch.cuda.is_available()}')
|
|
||||||
print(f'Dispositivos: {torch.cuda.device_count()}')
|
|
||||||
if torch.cuda.is_available():
|
|
||||||
print(f'GPU: {torch.cuda.get_device_name(0)}')
|
|
||||||
print(f'Memoria: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.2f} GB')
|
|
||||||
"
|
|
||||||
|
|
||||||
# Benchmark rápido
|
|
||||||
python3 -c "
|
|
||||||
import torch, time
|
|
||||||
a = torch.randn(4096, 4096, device='cuda')
|
|
||||||
b = torch.randn(4096, 4096, device='cuda')
|
|
||||||
start = time.time()
|
|
||||||
c = torch.matmul(a, b)
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
print(f'GPU time: {time.time() - start:.4f}s')
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Script de Stress Test
|
|
||||||
|
|
||||||
### Ejecutar Stress Test (2 minutos)
|
|
||||||
```bash
|
|
||||||
python3 /home/ren/gpu/rocm_stress_test.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Troubleshooting
|
|
||||||
|
|
||||||
### Si ROCm no detecta la GPU:
|
|
||||||
```bash
|
|
||||||
# Verificar módulos del kernel
|
|
||||||
lsmod | grep amdgpu
|
|
||||||
lsmod | grep kfd
|
|
||||||
|
|
||||||
# Recargar módulos
|
|
||||||
sudo modprobe amdgpu
|
|
||||||
sudo modprobe kfd
|
|
||||||
|
|
||||||
# Verificar logs
|
|
||||||
dmesg | grep amdgpu
|
|
||||||
```
|
|
||||||
|
|
||||||
### Si PyTorch no encuentra ROCm:
|
|
||||||
```bash
|
|
||||||
# Reinstalar PyTorch con ROCm
|
|
||||||
pip uninstall torch torchvision torchaudio
|
|
||||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Si hay errores de memoria:
|
|
||||||
```bash
|
|
||||||
# Limpiar cache de GPU
|
|
||||||
python3 -c "import torch; torch.cuda.empty_cache()"
|
|
||||||
|
|
||||||
# Verificar uso de memoria
|
|
||||||
rocm-smi --meminfo
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Monitoreo Continuo
|
|
||||||
|
|
||||||
### Terminal 1 - Monitor en tiempo real
|
|
||||||
```bash
|
|
||||||
watch -n 1 rocm-smi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Terminal 2 - Información detallada
|
|
||||||
```bash
|
|
||||||
rocm-smi --showtemp --showmeminfo vram --showmeminfo all
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💡 Ejemplos de Uso
|
|
||||||
|
|
||||||
### Cargar modelo en GPU
|
|
||||||
```python
|
|
||||||
import torch
|
|
||||||
from transformers import AutoModel
|
|
||||||
|
|
||||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
||||||
print(f"Usando dispositivo: {device}")
|
|
||||||
|
|
||||||
model = AutoModel.from_pretrained("bert-base-uncased")
|
|
||||||
model = model.to(device)
|
|
||||||
|
|
||||||
# Los tensores ahora se procesarán en la GPU
|
|
||||||
inputs = torch.tensor([1, 2, 3]).to(device)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entrenamiento en GPU
|
|
||||||
```python
|
|
||||||
import torch
|
|
||||||
import torch.nn as nn
|
|
||||||
|
|
||||||
device = torch.device("cuda")
|
|
||||||
model = tu_modelo().to(device)
|
|
||||||
criterion = nn.CrossEntropyLoss().to(device)
|
|
||||||
optimizer = torch.optim.Adam(model.parameters())
|
|
||||||
|
|
||||||
for epoch in range(epochs):
|
|
||||||
for batch in dataloader:
|
|
||||||
inputs, labels = batch
|
|
||||||
inputs, labels = inputs.to(device), labels.to(device)
|
|
||||||
|
|
||||||
optimizer.zero_grad()
|
|
||||||
outputs = model(inputs)
|
|
||||||
loss = criterion(outputs, labels)
|
|
||||||
loss.backward()
|
|
||||||
optimizer.step()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Optimizaciones
|
|
||||||
|
|
||||||
### Para mejor rendimiento:
|
|
||||||
```python
|
|
||||||
# Usar mixed precision (más rápido en RDNA2)
|
|
||||||
from torch.cuda.amp import autocast, GradScaler
|
|
||||||
|
|
||||||
scaler = GradScaler()
|
|
||||||
with autocast():
|
|
||||||
output = model(inputs)
|
|
||||||
loss = criterion(output, targets)
|
|
||||||
|
|
||||||
scaler.scale(loss).backward()
|
|
||||||
scaler.step(optimizer)
|
|
||||||
scaler.update()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📈 Comandos Útiles
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ver versión de ROCm
|
|
||||||
rocm-smi --version
|
|
||||||
|
|
||||||
# Verificar HSA
|
|
||||||
rocminfo
|
|
||||||
|
|
||||||
# Test de compatibilidad
|
|
||||||
python3 /opt/rocm/bin/rocprofiler-compute-test.py
|
|
||||||
|
|
||||||
# Verificar BLAS
|
|
||||||
python3 -c "import torch; print(torch.backends.mps.is_available())" # False en AMD
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚡ Performance Tips
|
|
||||||
|
|
||||||
1. **Siempre mueve datos a GPU**: `.to(device)`
|
|
||||||
2. **Usa batch sizes grandes**: Aprovecha los 16GB de VRAM
|
|
||||||
3. **Mixed precision**: Acelera el entrenamiento 1.5-2x
|
|
||||||
4. **DataLoader con num_workers**: Carga datos en paralelo
|
|
||||||
5. **torch.cuda.synchronize()**: Para benchmarks precisos
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
🔥 ROCm Stress Test - Prueba de resistencia para GPU AMD
|
|
||||||
Ejecuta operaciones intensivas durante 2 minutos y monitorea métricas
|
|
||||||
"""
|
|
||||||
|
|
||||||
import torch
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def print_header():
|
|
||||||
print("=" * 70)
|
|
||||||
print("🔥 ROCm STRESS TEST - AMD GPU STRESS TEST")
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"Inicio: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
print(f"GPU: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'N/A'}")
|
|
||||||
print(f"VRAM Total: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.2f} GB")
|
|
||||||
print("=" * 70)
|
|
||||||
print()
|
|
||||||
|
|
||||||
def get_gpu_stats():
|
|
||||||
"""Obtener estadísticas actuales de la GPU"""
|
|
||||||
if not torch.cuda.is_available():
|
|
||||||
return None
|
|
||||||
|
|
||||||
props = torch.cuda.get_device_properties(0)
|
|
||||||
mem_allocated = torch.cuda.memory_allocated(0) / 1024**3
|
|
||||||
mem_reserved = torch.cuda.memory_reserved(0) / 1024**3
|
|
||||||
mem_total = props.total_memory / 1024**3
|
|
||||||
|
|
||||||
return {
|
|
||||||
'mem_allocated': mem_allocated,
|
|
||||||
'mem_reserved': mem_reserved,
|
|
||||||
'mem_total': mem_total,
|
|
||||||
'mem_percent': (mem_allocated / mem_total) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
def stress_test(duration_seconds=120):
|
|
||||||
"""Ejecutar stress test durante duración especificada"""
|
|
||||||
print(f"🧪 Iniciando stress test por {duration_seconds} segundos...")
|
|
||||||
print(f" Presiona Ctrl+C para detener en cualquier momento\n")
|
|
||||||
|
|
||||||
if not torch.cuda.is_available():
|
|
||||||
print("❌ ERROR: CUDA/ROCm no está disponible!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
device = torch.device("cuda")
|
|
||||||
torch.cuda.set_per_process_memory_fraction(0.85, 0) # Usar 85% de VRAM
|
|
||||||
|
|
||||||
# Inicializar
|
|
||||||
results = {
|
|
||||||
'matmul_times': [],
|
|
||||||
'conv_times': [],
|
|
||||||
'reLU_times': [],
|
|
||||||
'softmax_times': [],
|
|
||||||
'iterations': 0,
|
|
||||||
'errors': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
iteration = 0
|
|
||||||
last_print = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
while time.time() - start_time < duration_seconds:
|
|
||||||
iteration += 1
|
|
||||||
results['iterations'] = iteration
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Operaciones intensivas de ML
|
|
||||||
# 1. Matriz multiplicación (varía el tamaño)
|
|
||||||
if iteration % 5 == 0:
|
|
||||||
size = 8192 # Matriz grande ocasionalmente
|
|
||||||
elif iteration % 3 == 0:
|
|
||||||
size = 4096
|
|
||||||
else:
|
|
||||||
size = 2048
|
|
||||||
|
|
||||||
a = torch.randn(size, size, device=device, dtype=torch.float16)
|
|
||||||
b = torch.randn(size, size, device=device, dtype=torch.float16)
|
|
||||||
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
t0 = time.time()
|
|
||||||
c = torch.matmul(a, b)
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
matmul_time = time.time() - t0
|
|
||||||
results['matmul_times'].append(matmul_time)
|
|
||||||
|
|
||||||
del a, b, c
|
|
||||||
|
|
||||||
# 2. Convolución 3D
|
|
||||||
x = torch.randn(32, 128, 64, 64, 64, device=device)
|
|
||||||
conv = torch.nn.Conv3d(128, 256, kernel_size=3, padding=1).to(device)
|
|
||||||
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
t0 = time.time()
|
|
||||||
out = conv(x)
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
conv_time = time.time() - t0
|
|
||||||
results['conv_times'].append(conv_time)
|
|
||||||
|
|
||||||
# 3. ReLU + BatchNorm
|
|
||||||
bn = torch.nn.BatchNorm2d(256).to(device)
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
t0 = time.time()
|
|
||||||
out = bn(torch.relu(out))
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
relu_time = time.time() - t0
|
|
||||||
results['reLU_times'].append(relu_time)
|
|
||||||
|
|
||||||
del x, out
|
|
||||||
|
|
||||||
# 4. Softmax grande
|
|
||||||
x = torch.randn(2048, 2048, device=device)
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
t0 = time.time()
|
|
||||||
softmax_out = torch.softmax(x, dim=-1)
|
|
||||||
torch.cuda.synchronize()
|
|
||||||
softmax_time = time.time() - t0
|
|
||||||
results['softmax_times'].append(softmax_time)
|
|
||||||
|
|
||||||
del softmax_out
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
results['errors'] += 1
|
|
||||||
if results['errors'] < 5: # Solo mostrar primeros errores
|
|
||||||
print(f"\n⚠️ Error en iteración {iteration}: {str(e)[:50]}")
|
|
||||||
|
|
||||||
# Progress cada segundo
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
if elapsed - last_print >= 1.0 or elapsed >= duration_seconds:
|
|
||||||
last_print = elapsed
|
|
||||||
progress = (elapsed / duration_seconds) * 100
|
|
||||||
|
|
||||||
# Stats
|
|
||||||
stats = get_gpu_stats()
|
|
||||||
matmul_avg = sum(results['matmul_times'][-10:]) / len(results['matmul_times'][-10:]) if results['matmul_times'] else 0
|
|
||||||
|
|
||||||
print(f"\r⏱️ Iteración {iteration:4d} | "
|
|
||||||
f"Tiempo: {elapsed:6.1f}s/{duration_seconds}s [{progress:5.1f}%] | "
|
|
||||||
f"VRAM: {stats['mem_allocated']:5.2f}GB/{stats['mem_total']:.2f}GB ({stats['mem_percent']:5.1f}%) | "
|
|
||||||
f"MatMul avg: {matmul_avg*1000:6.2f}ms | "
|
|
||||||
f"Iter/s: {iteration/elapsed:5.2f}",
|
|
||||||
end='', flush=True)
|
|
||||||
|
|
||||||
# Pequeña pausa para evitar sobrecarga
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\n⏹️ Interrumpido por el usuario")
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
print(f" Duración real: {elapsed:.1f} segundos")
|
|
||||||
print(f" Iteraciones: {iteration}")
|
|
||||||
|
|
||||||
print("\n")
|
|
||||||
return results
|
|
||||||
|
|
||||||
def print_summary(results, duration):
|
|
||||||
"""Imprimir resumen de resultados"""
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("📊 RESUMEN DEL STRESS TEST")
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"Duración total: {duration:.2f} segundos")
|
|
||||||
print(f"Iteraciones completadas: {results['iterations']}")
|
|
||||||
print(f"Errores: {results['errors']}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if results['matmul_times']:
|
|
||||||
matmul_avg = sum(results['matmul_times']) / len(results['matmul_times'])
|
|
||||||
matmul_min = min(results['matmul_times'])
|
|
||||||
matmul_max = max(results['matmul_times'])
|
|
||||||
matmul_last10_avg = sum(results['matmul_times'][-10:]) / len(results['matmul_times'][-10:])
|
|
||||||
print(f"🔢 MATRIZ MULTIPLICACIÓN (2048-8192)")
|
|
||||||
print(f" Promedio: {matmul_avg*1000:8.2f} ms")
|
|
||||||
print(f" Últimas 10: {matmul_last10_avg*1000:8.2f} ms")
|
|
||||||
print(f" Mínimo: {matmul_min*1000:8.2f} ms")
|
|
||||||
print(f" Máximo: {matmul_max*1000:8.2f} ms")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if results['conv_times']:
|
|
||||||
conv_avg = sum(results['conv_times']) / len(results['conv_times'])
|
|
||||||
conv_min = min(results['conv_times'])
|
|
||||||
conv_max = max(results['conv_times'])
|
|
||||||
print(f"🧮 CONVOLUCIÓN 3D (32x128x64³)")
|
|
||||||
print(f" Promedio: {conv_avg*1000:8.2f} ms")
|
|
||||||
print(f" Mínimo: {conv_min*1000:8.2f} ms")
|
|
||||||
print(f" Máximo: {conv_max*1000:8.2f} ms")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if results['reLU_times']:
|
|
||||||
relu_avg = sum(results['reLU_times']) / len(results['reLU_times'])
|
|
||||||
relu_min = min(results['reLU_times'])
|
|
||||||
relu_max = max(results['reLU_times'])
|
|
||||||
print(f"⚡ ReLU + BatchNorm")
|
|
||||||
print(f" Promedio: {relu_avg*1000:8.4f} ms")
|
|
||||||
print(f" Mínimo: {relu_min*1000:8.4f} ms")
|
|
||||||
print(f" Máximo: {relu_max*1000:8.4f} ms")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if results['softmax_times']:
|
|
||||||
softmax_avg = sum(results['softmax_times']) / len(results['softmax_times'])
|
|
||||||
softmax_min = min(results['softmax_times'])
|
|
||||||
softmax_max = max(results['softmax_times'])
|
|
||||||
print(f"🔥 Softmax (2048x2048)")
|
|
||||||
print(f" Promedio: {softmax_avg*1000:8.2f} ms")
|
|
||||||
print(f" Mínimo: {softmax_min*1000:8.2f} ms")
|
|
||||||
print(f" Máximo: {softmax_max*1000:8.2f} ms")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Performance score
|
|
||||||
total_ops = results['iterations']
|
|
||||||
if total_ops > 0 and duration > 0:
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"✅ TEST COMPLETADO")
|
|
||||||
print(f" 📈 {total_ops} operaciones en {duration:.1f} segundos")
|
|
||||||
print(f" ⚡ {total_ops/duration:.2f} operaciones/segundo")
|
|
||||||
print(f" 💾 Uso de VRAM: Hasta ~85% (configurado)")
|
|
||||||
print("=" * 70)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Calcular GFLOPS aproximado para matmul
|
|
||||||
if results['matmul_times']:
|
|
||||||
# GFLOPS = 2 * n^3 / (time * 10^9) para matriz n x n
|
|
||||||
avg_matmul_ms = (sum(results['matmul_times']) / len(results['matmul_times'])) * 1000
|
|
||||||
avg_n = sum([2048 if i%3==0 else 4096 if i%5==0 else 2048 for i in range(len(results['matmul_times']))]) / len(results['matmul_times'])
|
|
||||||
gflops = (2 * (avg_n**3)) / (avg_matmul_ms / 1000) / 1e9
|
|
||||||
print(f"🚀 RENDIMIENTO ESTIMADO")
|
|
||||||
print(f" ~{gflops:.2f} GFLOPS (matriz multiplicación)")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print_header()
|
|
||||||
|
|
||||||
# Verificar ROCm
|
|
||||||
if not torch.cuda.is_available():
|
|
||||||
print("❌ ERROR: ROCm/CUDA no está disponible!")
|
|
||||||
print(" Ejecuta: export HSA_OVERRIDE_GFX_VERSION=10.3.0")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Ejecutar stress test
|
|
||||||
duration = 120 # 2 minutos
|
|
||||||
start = time.time()
|
|
||||||
results = stress_test(duration)
|
|
||||||
actual_duration = time.time() - start
|
|
||||||
|
|
||||||
# Mostrar resumen
|
|
||||||
print_summary(results, actual_duration)
|
|
||||||
|
|
||||||
# Limpiar
|
|
||||||
torch.cuda.empty_cache()
|
|
||||||
print("🧹 Cache de GPU limpiado")
|
|
||||||
print("\n✅ Stress test finalizado")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
"""
|
"""Export de API."""
|
||||||
API package for CBCFacil
|
from .routes import api_bp, init_api, process_manager
|
||||||
"""
|
|
||||||
|
|
||||||
from .routes import create_app
|
__all__ = ["api_bp", "init_api", "process_manager"]
|
||||||
|
|
||||||
__all__ = ['create_app']
|
|
||||||
|
|||||||
522
api/routes.py
522
api/routes.py
@@ -1,280 +1,322 @@
|
|||||||
"""
|
"""
|
||||||
Flask API routes for CBCFacil dashboard
|
Rutas API de Flask.
|
||||||
"""
|
"""
|
||||||
import os
|
import json
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List
|
from typing import Any, Optional
|
||||||
from flask import Flask, render_template, request, jsonify, send_from_directory
|
|
||||||
from flask_cors import CORS
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
|
from flask.typing import ResponseValue
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from storage import processed_registry
|
from core.process_manager import ProcessManager as CoreProcessManager
|
||||||
from services.webdav_service import webdav_service
|
from services import WebDAVService
|
||||||
from services import vram_manager
|
from watchers import RemoteFolderWatcher
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
# Logger
|
||||||
"""Create and configure Flask application"""
|
logger = logging.getLogger(__name__)
|
||||||
app = Flask(__name__)
|
|
||||||
CORS(app)
|
|
||||||
|
|
||||||
# Configure app
|
# Blueprint
|
||||||
app.config['SECRET_KEY'] = settings.DASHBOARD_SECRET_KEY or os.urandom(24)
|
api_bp = Blueprint("api", __name__)
|
||||||
app.config['DOWNLOADS_FOLDER'] = str(settings.LOCAL_DOWNLOADS_PATH)
|
|
||||||
|
|
||||||
@app.route('/')
|
# Instancias globales (se inicializan en main.py)
|
||||||
def index():
|
webdav_service: WebDAVService = None
|
||||||
"""Dashboard home page"""
|
remote_watcher: RemoteFolderWatcher = None
|
||||||
return render_template('index.html')
|
process_manager: CoreProcessManager = None
|
||||||
|
|
||||||
@app.route('/api/files')
|
|
||||||
def get_files():
|
|
||||||
"""Get list of audio files"""
|
|
||||||
try:
|
|
||||||
files = get_audio_files()
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'files': files,
|
|
||||||
'total': len(files),
|
|
||||||
'processed': sum(1 for f in files if f['processed']),
|
|
||||||
'pending': sum(1 for f in files if not f['processed'])
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error getting files: {e}")
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'message': f"Error: {str(e)}"
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
@app.route('/api/reprocess', methods=['POST'])
|
def init_api(
|
||||||
def reprocess_file():
|
pm: CoreProcessManager,
|
||||||
"""Reprocess a file"""
|
wd_service: Optional[WebDAVService] = None,
|
||||||
try:
|
watcher: Optional[RemoteFolderWatcher] = None,
|
||||||
data = request.get_json()
|
) -> None:
|
||||||
file_path = data.get('path')
|
"""Inicializa las referencias a los servicios."""
|
||||||
source = data.get('source', 'local')
|
global process_manager, webdav_service, remote_watcher
|
||||||
|
process_manager = pm
|
||||||
|
webdav_service = wd_service
|
||||||
|
remote_watcher = watcher
|
||||||
|
|
||||||
if not file_path:
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'message': "Path del archivo es requerido"
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# TODO: Implement file reprocessing
|
class LocalProcessManager:
|
||||||
# This would trigger the main processing loop
|
"""
|
||||||
|
Gestor local de archivos para la API.
|
||||||
|
|
||||||
return jsonify({
|
Provee métodos para obtener detalles de archivos y transcripciones
|
||||||
'success': True,
|
desde el sistema de archivos local.
|
||||||
'message': f"Archivo {Path(file_path).name} enviado a reprocesamiento"
|
"""
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
def __init__(self) -> None:
|
||||||
app.logger.error(f"Error reprocessing file: {e}")
|
self._transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'message': f"Error: {str(e)}"
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
@app.route('/api/mark-unprocessed', methods=['POST'])
|
def get_all_files_detailed(self) -> list[dict[str, Any]]:
|
||||||
def mark_unprocessed():
|
"""Obtiene información detallada de todos los archivos."""
|
||||||
"""Mark file as unprocessed"""
|
files_data = []
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
file_path = data.get('path')
|
|
||||||
|
|
||||||
if not file_path:
|
if self._transcriptions_dir.exists():
|
||||||
return jsonify({
|
for f in self._transcriptions_dir.iterdir():
|
||||||
'success': False,
|
if f.is_file() and not f.name.startswith("."):
|
||||||
'message': "Path del archivo es requerido"
|
file_info = self._get_file_detail(f)
|
||||||
}), 400
|
files_data.append(file_info)
|
||||||
|
|
||||||
success = processed_registry.remove(file_path)
|
return sorted(files_data, key=lambda x: x["modified"], reverse=True)
|
||||||
|
|
||||||
if success:
|
def _get_file_detail(self, file_path: Path) -> dict[str, Any]:
|
||||||
return jsonify({
|
"""Obtiene información detallada de un archivo."""
|
||||||
'success': True,
|
filename = file_path.name
|
||||||
'message': "Archivo marcado como no procesado"
|
stat = file_path.stat()
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'message': "No se pudo marcar como no procesado"
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
except Exception as e:
|
# Buscar transcripción si existe archivo .txt
|
||||||
app.logger.error(f"Error marking unprocessed: {e}")
|
transcription_text = None
|
||||||
return jsonify({
|
if file_path.suffix != ".txt":
|
||||||
'success': False,
|
txt_path = file_path.with_suffix(".txt")
|
||||||
'message': f"Error: {str(e)}"
|
if txt_path.exists():
|
||||||
}), 500
|
transcription_text = txt_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
@app.route('/api/refresh')
|
return {
|
||||||
def refresh_files():
|
"name": filename,
|
||||||
"""Refresh file list"""
|
"path": str(file_path),
|
||||||
try:
|
"size": stat.st_size,
|
||||||
processed_registry.load()
|
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||||
files = get_audio_files()
|
"modified": stat.st_mtime,
|
||||||
return jsonify({
|
"modified_iso": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
'success': True,
|
"extension": file_path.suffix.lower(),
|
||||||
'message': "Lista de archivos actualizada",
|
"status": "transcribed" if transcription_text else "pending",
|
||||||
'files': files
|
"transcription": transcription_text,
|
||||||
})
|
"transcription_length": len(transcription_text or ""),
|
||||||
except Exception as e:
|
}
|
||||||
app.logger.error(f"Error refreshing files: {e}")
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'message': f"Error: {str(e)}"
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
@app.route('/downloads/<path:filename>')
|
def get_file_detail(self, filename: str) -> Optional[dict[str, Any]]:
|
||||||
def download_file(filename):
|
"""Obtiene información detallada de un archivo específico."""
|
||||||
"""Download file"""
|
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||||
try:
|
|
||||||
# Validate path to prevent traversal and injection attacks
|
|
||||||
normalized = Path(filename).resolve()
|
|
||||||
base_downloads = Path(str(settings.LOCAL_DOWNLOADS_PATH)).resolve()
|
|
||||||
base_docx = Path(str(settings.LOCAL_DOCX)).resolve()
|
|
||||||
if '..' in filename or filename.startswith('/') or \
|
|
||||||
normalized.parts[0] in ['..', '...'] if len(normalized.parts) > 0 else False or \
|
|
||||||
not (normalized == base_downloads or normalized.is_relative_to(base_downloads) or
|
|
||||||
normalized == base_docx or normalized.is_relative_to(base_docx)):
|
|
||||||
return jsonify({'error': 'Invalid filename'}), 400
|
|
||||||
|
|
||||||
# Try downloads directory
|
# Buscar archivo con cualquier extensión que coincida con el nombre base
|
||||||
downloads_path = settings.LOCAL_DOWNLOADS_PATH / filename
|
name_without_ext = Path(filename).stem
|
||||||
if downloads_path.exists():
|
for f in transcriptions_dir.iterdir():
|
||||||
return send_from_directory(str(settings.LOCAL_DOWNLOADS_PATH), filename)
|
if f.stem == name_without_ext:
|
||||||
|
return self._get_file_detail(f)
|
||||||
|
|
||||||
# Try resumenes_docx directory
|
return None
|
||||||
docx_path = settings.LOCAL_DOCX / filename
|
|
||||||
if docx_path.exists():
|
|
||||||
return send_from_directory(str(settings.LOCAL_DOCX), filename)
|
|
||||||
|
|
||||||
return jsonify({'error': 'File not found'}), 404
|
def get_transcription_data(self, filename: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""Obtiene la transcripción de un archivo."""
|
||||||
|
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||||
|
name_without_ext = Path(filename).stem
|
||||||
|
|
||||||
except Exception as e:
|
# Buscar archivo .txt
|
||||||
app.logger.error(f"Error downloading file: {e}")
|
txt_path = transcriptions_dir / f"{name_without_ext}.txt"
|
||||||
return jsonify({'error': 'File not found'}), 404
|
if txt_path.exists():
|
||||||
|
text = txt_path.read_text(encoding="utf-8")
|
||||||
@app.route('/health')
|
return {
|
||||||
def health_check():
|
"text": text,
|
||||||
"""Health check endpoint"""
|
"created_at": datetime.fromtimestamp(txt_path.stat().st_mtime).isoformat(),
|
||||||
gpu_info = vram_manager.get_usage()
|
"metadata": {},
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'healthy',
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'processed_files_count': processed_registry.count(),
|
|
||||||
'gpu': gpu_info,
|
|
||||||
'config': {
|
|
||||||
'webdav_configured': settings.has_webdav_config,
|
|
||||||
'ai_configured': settings.has_ai_config,
|
|
||||||
'debug': settings.DEBUG
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return app
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_audio_files() -> List[Dict[str, Any]]:
|
# Instancia local para detalles de archivos
|
||||||
"""Get list of audio files from WebDAV and local"""
|
local_pm = LocalProcessManager()
|
||||||
files = []
|
|
||||||
|
|
||||||
# Get files from WebDAV
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
try:
|
|
||||||
webdav_files = webdav_service.list(settings.REMOTE_AUDIOS_FOLDER)
|
|
||||||
for file_path in webdav_files:
|
|
||||||
normalized_path = webdav_service.normalize_path(file_path)
|
|
||||||
base_name = Path(normalized_path).name
|
|
||||||
|
|
||||||
if any(normalized_path.lower().endswith(ext) for ext in settings.AUDIO_EXTENSIONS):
|
|
||||||
is_processed = processed_registry.is_processed(normalized_path)
|
|
||||||
|
|
||||||
files.append({
|
|
||||||
'filename': base_name,
|
|
||||||
'path': normalized_path,
|
|
||||||
'source': 'webdav',
|
|
||||||
'processed': is_processed,
|
|
||||||
'size': 'Unknown',
|
|
||||||
'last_modified': 'Unknown',
|
|
||||||
'available_formats': get_available_formats(base_name)
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error getting WebDAV files: {e}")
|
|
||||||
|
|
||||||
# Get local files
|
|
||||||
try:
|
|
||||||
if settings.LOCAL_DOWNLOADS_PATH.exists():
|
|
||||||
for ext in settings.AUDIO_EXTENSIONS:
|
|
||||||
for file_path in settings.LOCAL_DOWNLOADS_PATH.glob(f"*{ext}"):
|
|
||||||
stat = file_path.stat()
|
|
||||||
is_processed = processed_registry.is_processed(file_path.name)
|
|
||||||
|
|
||||||
files.append({
|
|
||||||
'filename': file_path.name,
|
|
||||||
'path': str(file_path),
|
|
||||||
'source': 'local',
|
|
||||||
'processed': is_processed,
|
|
||||||
'size': format_size(stat.st_size),
|
|
||||||
'last_modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
'available_formats': get_available_formats(file_path.name)
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error getting local files: {e}")
|
|
||||||
|
|
||||||
# Remove duplicates (WebDAV takes precedence)
|
|
||||||
unique_files = {}
|
|
||||||
for file in files:
|
|
||||||
key = file['filename']
|
|
||||||
if key not in unique_files or file['source'] == 'webdav':
|
|
||||||
unique_files[key] = file
|
|
||||||
|
|
||||||
return sorted(unique_files.values(), key=lambda x: x['filename'])
|
|
||||||
|
|
||||||
|
|
||||||
def get_available_formats(audio_filename: str) -> Dict[str, bool]:
|
@api_bp.route("/health", methods=["GET"])
|
||||||
"""Check which output formats are available for an audio file"""
|
def health_check() -> ResponseValue:
|
||||||
base_name = Path(audio_filename).stem
|
"""Health check endpoint."""
|
||||||
|
return jsonify({"status": "ok"}), 200
|
||||||
|
|
||||||
formats = {
|
|
||||||
'txt': False,
|
@api_bp.route("/status", methods=["GET"])
|
||||||
'md': False,
|
def status() -> ResponseValue:
|
||||||
'pdf': False,
|
"""Estado del sistema."""
|
||||||
'docx': False
|
status_data = {
|
||||||
|
"webdav_configured": settings.has_webdav_config,
|
||||||
|
"webdav_connected": False,
|
||||||
|
"watcher": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
directories_to_check = [
|
# Verificar conexión WebDAV
|
||||||
settings.LOCAL_DOWNLOADS_PATH,
|
if settings.has_webdav_config and webdav_service:
|
||||||
settings.LOCAL_DOCX
|
try:
|
||||||
]
|
status_data["webdav_connected"] = webdav_service.test_connection()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebDAV connection test failed: {e}")
|
||||||
|
|
||||||
for directory in directories_to_check:
|
# Estado del watcher
|
||||||
if not directory.exists():
|
if remote_watcher:
|
||||||
continue
|
status_data["watcher"] = remote_watcher.get_status()
|
||||||
|
|
||||||
for ext in formats.keys():
|
return jsonify(status_data), 200
|
||||||
name_variants = [
|
|
||||||
base_name,
|
|
||||||
f"{base_name}_unificado",
|
|
||||||
base_name.replace(' ', '_'),
|
|
||||||
f"{base_name.replace(' ', '_')}_unificado",
|
|
||||||
]
|
|
||||||
|
|
||||||
for name_variant in name_variants:
|
|
||||||
file_path = directory / f"{name_variant}.{ext}"
|
|
||||||
if file_path.exists():
|
|
||||||
formats[ext] = True
|
|
||||||
break
|
|
||||||
|
|
||||||
return formats
|
|
||||||
|
|
||||||
|
|
||||||
def format_size(size_bytes: int) -> str:
|
@api_bp.route("/files", methods=["GET"])
|
||||||
"""Format size in human-readable format"""
|
def list_files() -> ResponseValue:
|
||||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
"""Lista archivos en la carpeta local."""
|
||||||
if size_bytes < 1024.0:
|
try:
|
||||||
return f"{size_bytes:.1f} {unit}"
|
files = []
|
||||||
size_bytes /= 1024.0
|
downloads_dir = settings.DOWNLOADS_DIR
|
||||||
return f"{size_bytes:.1f} TB"
|
|
||||||
|
if downloads_dir.exists():
|
||||||
|
for f in downloads_dir.iterdir():
|
||||||
|
if f.is_file() and not f.name.startswith("."):
|
||||||
|
files.append({
|
||||||
|
"name": f.name,
|
||||||
|
"size": f.stat().st_size,
|
||||||
|
"modified": f.stat().st_mtime,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"files": files, "count": len(files)}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing files: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/trigger", methods=["POST"])
|
||||||
|
def trigger_check() -> ResponseValue:
|
||||||
|
"""Fuerza una verificación de archivos remotos."""
|
||||||
|
try:
|
||||||
|
if remote_watcher:
|
||||||
|
remote_watcher.check_now()
|
||||||
|
return jsonify({"message": "Check triggered"}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "Watcher not initialized"}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering check: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/remote-files", methods=["GET"])
|
||||||
|
def list_remote_files() -> ResponseValue:
|
||||||
|
"""Lista archivos en la carpeta remota de Nextcloud."""
|
||||||
|
try:
|
||||||
|
if not settings.has_webdav_config:
|
||||||
|
return jsonify({"error": "WebDAV not configured"}), 500
|
||||||
|
|
||||||
|
path = request.args.get("path", settings.WATCHED_REMOTE_PATH)
|
||||||
|
files = webdav_service.list_files(path)
|
||||||
|
|
||||||
|
return jsonify({"files": files, "path": path}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing remote files: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/files-detailed", methods=["GET"])
|
||||||
|
def list_files_detailed() -> ResponseValue:
|
||||||
|
"""Lista archivos con información detallada y estado de transcripción."""
|
||||||
|
try:
|
||||||
|
files = local_pm.get_all_files_detailed()
|
||||||
|
logger.info(f"Listing {len(files)} files with detailed info")
|
||||||
|
return jsonify({"files": files, "count": len(files)}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing detailed files: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/transcription/<path:filename>", methods=["GET"])
|
||||||
|
def get_transcription(filename: str) -> ResponseValue:
|
||||||
|
"""Obtiene la transcripción de un archivo específico."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Getting transcription for: {filename}")
|
||||||
|
|
||||||
|
# Validar que el archivo existe
|
||||||
|
file_detail = local_pm.get_file_detail(filename)
|
||||||
|
if not file_detail:
|
||||||
|
logger.warning(f"File not found: {filename}")
|
||||||
|
return jsonify({"error": "File not found"}), 404
|
||||||
|
|
||||||
|
# Obtener transcripción
|
||||||
|
transcription_data = local_pm.get_transcription_data(filename)
|
||||||
|
|
||||||
|
if not transcription_data:
|
||||||
|
logger.info(f"No transcription found for: {filename}")
|
||||||
|
return jsonify({
|
||||||
|
"file_name": filename,
|
||||||
|
"transcription": None,
|
||||||
|
"status": file_detail.get("status", "pending"),
|
||||||
|
"metadata": {
|
||||||
|
"size_mb": file_detail.get("size_mb"),
|
||||||
|
"extension": file_detail.get("extension"),
|
||||||
|
"modified": file_detail.get("modified_iso"),
|
||||||
|
},
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"file_name": filename,
|
||||||
|
"transcription": transcription_data["text"],
|
||||||
|
"status": "transcribed",
|
||||||
|
"created_at": transcription_data["created_at"],
|
||||||
|
"metadata": {
|
||||||
|
**file_detail.get("metadata", {}),
|
||||||
|
"transcription_length": len(transcription_data["text"]),
|
||||||
|
},
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting transcription for {filename}: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/summary/<path:filename>", methods=["GET"])
|
||||||
|
def get_summary(filename: str) -> ResponseValue:
|
||||||
|
"""Obtiene el resumen (markdown) de un archivo."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Getting summary for: {filename}")
|
||||||
|
|
||||||
|
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||||
|
name_without_ext = Path(filename).stem
|
||||||
|
|
||||||
|
# Buscar archivo .md con el resumen
|
||||||
|
md_path = transcriptions_dir / f"{name_without_ext}.md"
|
||||||
|
|
||||||
|
if not md_path.exists():
|
||||||
|
logger.warning(f"Summary not found: {filename}")
|
||||||
|
return jsonify({"error": "Summary not found"}), 404
|
||||||
|
|
||||||
|
summary_content = md_path.read_text(encoding="utf-8")
|
||||||
|
stat = md_path.stat()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"file_name": filename,
|
||||||
|
"summary": summary_content,
|
||||||
|
"created_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
"size": stat.st_size,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting summary for {filename}: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/pdf/<path:filename>", methods=["GET"])
|
||||||
|
def get_pdf(filename: str):
|
||||||
|
"""Descarga el PDF de un archivo."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Getting PDF for: {filename}")
|
||||||
|
|
||||||
|
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||||
|
name_without_ext = Path(filename).stem
|
||||||
|
|
||||||
|
# Buscar archivo .pdf
|
||||||
|
pdf_path = transcriptions_dir / f"{name_without_ext}.pdf"
|
||||||
|
|
||||||
|
if not pdf_path.exists():
|
||||||
|
logger.warning(f"PDF not found: {filename}")
|
||||||
|
return jsonify({"error": "PDF not found"}), 404
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
pdf_path,
|
||||||
|
mimetype="application/pdf",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{name_without_ext}.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting PDF for {filename}: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"""
|
|
||||||
AI Service - Unified interface for AI providers
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
from .core import AIProcessingError
|
|
||||||
from .services.ai.provider_factory import AIProviderFactory, ai_provider_factory
|
|
||||||
|
|
||||||
|
|
||||||
class AIService:
|
|
||||||
"""Unified service for AI operations with provider fallback"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._factory: Optional[AIProviderFactory] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def factory(self) -> AIProviderFactory:
|
|
||||||
"""Lazy initialization of provider factory"""
|
|
||||||
if self._factory is None:
|
|
||||||
self._factory = ai_provider_factory
|
|
||||||
return self._factory
|
|
||||||
|
|
||||||
def generate_text(
|
|
||||||
self,
|
|
||||||
prompt: str,
|
|
||||||
provider: Optional[str] = None,
|
|
||||||
max_tokens: int = 4096
|
|
||||||
) -> str:
|
|
||||||
"""Generate text using AI provider"""
|
|
||||||
try:
|
|
||||||
ai_provider = self.factory.get_provider(provider or 'gemini')
|
|
||||||
return ai_provider.generate(prompt, max_tokens=max_tokens)
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"AI generation failed: {e}")
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
def summarize(self, text: str, **kwargs) -> str:
|
|
||||||
"""Generate summary of text"""
|
|
||||||
try:
|
|
||||||
provider = self.factory.get_best_provider()
|
|
||||||
return provider.summarize(text, **kwargs)
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"Summarization failed: {e}")
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
def correct_text(self, text: str, **kwargs) -> str:
|
|
||||||
"""Correct grammar and spelling in text"""
|
|
||||||
try:
|
|
||||||
provider = self.factory.get_best_provider()
|
|
||||||
return provider.correct_text(text, **kwargs)
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"Text correction failed: {e}")
|
|
||||||
return text # Return original on error
|
|
||||||
|
|
||||||
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Classify content into categories"""
|
|
||||||
try:
|
|
||||||
provider = self.factory.get_best_provider()
|
|
||||||
return provider.classify_content(text, **kwargs)
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"Classification failed: {e}")
|
|
||||||
return {"category": "otras_clases", "confidence": 0.0}
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
ai_service = AIService()
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""
|
|
||||||
Gemini AI Provider implementation
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
import shutil
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from ..config import settings
|
|
||||||
from ..core import AIProcessingError
|
|
||||||
from .base_provider import AIProvider
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiProvider(AIProvider):
|
|
||||||
"""Gemini AI provider using CLI or API"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._cli_path = settings.GEMINI_CLI_PATH or shutil.which("gemini")
|
|
||||||
self._api_key = settings.GEMINI_API_KEY
|
|
||||||
self._flash_model = settings.GEMINI_FLASH_MODEL
|
|
||||||
self._pro_model = settings.GEMINI_PRO_MODEL
|
|
||||||
self._session = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "Gemini"
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
"""Check if Gemini is available"""
|
|
||||||
return bool(self._cli_path or self._api_key)
|
|
||||||
|
|
||||||
def _run_cli(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str:
|
|
||||||
"""Run Gemini CLI with prompt"""
|
|
||||||
if not self._cli_path:
|
|
||||||
raise AIProcessingError("Gemini CLI not available")
|
|
||||||
|
|
||||||
model = self._flash_model if use_flash else self._pro_model
|
|
||||||
cmd = [self._cli_path, model, prompt]
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=timeout,
|
|
||||||
shell=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if process.returncode != 0:
|
|
||||||
error_msg = process.stderr or "Unknown error"
|
|
||||||
raise AIProcessingError(f"Gemini CLI failed: {error_msg}")
|
|
||||||
|
|
||||||
return process.stdout.strip()
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise AIProcessingError(f"Gemini CLI timed out after {timeout}s")
|
|
||||||
except Exception as e:
|
|
||||||
raise AIProcessingError(f"Gemini CLI error: {e}")
|
|
||||||
|
|
||||||
def _call_api(self, prompt: str, use_flash: bool = True, timeout: int = 180) -> str:
|
|
||||||
"""Call Gemini API"""
|
|
||||||
if not self._api_key:
|
|
||||||
raise AIProcessingError("Gemini API key not configured")
|
|
||||||
|
|
||||||
model = self._flash_model if use_flash else self._pro_model
|
|
||||||
|
|
||||||
# Initialize session if needed
|
|
||||||
if self._session is None:
|
|
||||||
self._session = requests.Session()
|
|
||||||
adapter = requests.adapters.HTTPAdapter(
|
|
||||||
pool_connections=10,
|
|
||||||
pool_maxsize=20
|
|
||||||
)
|
|
||||||
self._session.mount('https://', adapter)
|
|
||||||
|
|
||||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"contents": [{
|
|
||||||
"parts": [{"text": prompt}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {"key": self._api_key}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self._session.post(
|
|
||||||
url,
|
|
||||||
json=payload,
|
|
||||||
params=params,
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if "candidates" not in data or not data["candidates"]:
|
|
||||||
raise AIProcessingError("Empty response from Gemini API")
|
|
||||||
|
|
||||||
candidate = data["candidates"][0]
|
|
||||||
if "content" not in candidate or "parts" not in candidate["content"]:
|
|
||||||
raise AIProcessingError("Invalid response format from Gemini API")
|
|
||||||
|
|
||||||
result = candidate["content"]["parts"][0]["text"]
|
|
||||||
return result.strip()
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
raise AIProcessingError(f"Gemini API request failed: {e}")
|
|
||||||
except (KeyError, IndexError, ValueError) as e:
|
|
||||||
raise AIProcessingError(f"Gemini API response error: {e}")
|
|
||||||
|
|
||||||
def _run(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str:
|
|
||||||
"""Run Gemini with fallback between CLI and API"""
|
|
||||||
# Try CLI first if available
|
|
||||||
if self._cli_path:
|
|
||||||
try:
|
|
||||||
return self._run_cli(prompt, use_flash, timeout)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Gemini CLI failed, trying API: {e}")
|
|
||||||
|
|
||||||
# Fallback to API
|
|
||||||
if self._api_key:
|
|
||||||
api_timeout = timeout if timeout < 180 else 180
|
|
||||||
return self._call_api(prompt, use_flash, api_timeout)
|
|
||||||
|
|
||||||
raise AIProcessingError("No Gemini provider available (CLI or API)")
|
|
||||||
|
|
||||||
def summarize(self, text: str, **kwargs) -> str:
|
|
||||||
"""Generate summary using Gemini"""
|
|
||||||
prompt = f"""Summarize the following text:
|
|
||||||
|
|
||||||
{text}
|
|
||||||
|
|
||||||
Provide a clear, concise summary in Spanish."""
|
|
||||||
return self._run(prompt, use_flash=True)
|
|
||||||
|
|
||||||
def correct_text(self, text: str, **kwargs) -> str:
|
|
||||||
"""Correct text using Gemini"""
|
|
||||||
prompt = f"""Correct the following text for grammar, spelling, and clarity:
|
|
||||||
|
|
||||||
{text}
|
|
||||||
|
|
||||||
Return only the corrected text, nothing else."""
|
|
||||||
return self._run(prompt, use_flash=True)
|
|
||||||
|
|
||||||
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Classify content using Gemini"""
|
|
||||||
categories = ["historia", "analisis_contable", "instituciones_gobierno", "otras_clases"]
|
|
||||||
|
|
||||||
prompt = f"""Classify the following text into one of these categories:
|
|
||||||
- historia
|
|
||||||
- analisis_contable
|
|
||||||
- instituciones_gobierno
|
|
||||||
- otras_clases
|
|
||||||
|
|
||||||
Text: {text}
|
|
||||||
|
|
||||||
Return only the category name, nothing else."""
|
|
||||||
result = self._run(prompt, use_flash=True).lower()
|
|
||||||
|
|
||||||
# Validate result
|
|
||||||
if result not in categories:
|
|
||||||
result = "otras_clases"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"category": result,
|
|
||||||
"confidence": 0.9,
|
|
||||||
"provider": self.name
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""
|
|
||||||
Processed files registry using repository pattern
|
|
||||||
"""
|
|
||||||
import fcntl
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Set, Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from ..config import settings
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedRegistry:
|
|
||||||
"""Registry for tracking processed files with caching and file locking"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._cache: Set[str] = set()
|
|
||||||
self._cache_time: Optional[datetime] = None
|
|
||||||
self._cache_ttl = 60
|
|
||||||
self._initialized = False
|
|
||||||
|
|
||||||
def initialize(self) -> None:
|
|
||||||
"""Initialize the registry"""
|
|
||||||
self.load()
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
def load(self) -> Set[str]:
|
|
||||||
"""Load processed files from disk with caching"""
|
|
||||||
now = datetime.utcnow()
|
|
||||||
if self._cache and self._cache_time and (now - self._cache_time).total_seconds() < self._cache_ttl:
|
|
||||||
return self._cache.copy()
|
|
||||||
|
|
||||||
processed = set()
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if registry_path.exists():
|
|
||||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
|
||||||
for raw_line in f:
|
|
||||||
line = raw_line.strip()
|
|
||||||
if line and not line.startswith('#'):
|
|
||||||
processed.add(line)
|
|
||||||
base_name = Path(line).name
|
|
||||||
processed.add(base_name)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error reading processed files registry: {e}")
|
|
||||||
|
|
||||||
self._cache = processed
|
|
||||||
self._cache_time = now
|
|
||||||
return processed.copy()
|
|
||||||
|
|
||||||
def save(self, file_path: str) -> None:
|
|
||||||
"""Add file to processed registry with file locking"""
|
|
||||||
if not file_path:
|
|
||||||
return
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(registry_path, 'a', encoding='utf-8') as f:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
||||||
try:
|
|
||||||
if file_path not in self._cache:
|
|
||||||
f.write(file_path + "\n")
|
|
||||||
self._cache.add(file_path)
|
|
||||||
self.logger.debug(f"Added {file_path} to processed registry")
|
|
||||||
finally:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error saving to processed files registry: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def is_processed(self, file_path: str) -> bool:
|
|
||||||
"""Check if file has been processed"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
if file_path in self._cache:
|
|
||||||
return True
|
|
||||||
basename = Path(file_path).name
|
|
||||||
if basename in self._cache:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def remove(self, file_path: str) -> bool:
|
|
||||||
"""Remove file from processed registry"""
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not registry_path.exists():
|
|
||||||
return False
|
|
||||||
lines_to_keep = []
|
|
||||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
|
||||||
for line in f:
|
|
||||||
if line.strip() != file_path and Path(line.strip()).name != Path(file_path).name:
|
|
||||||
lines_to_keep.append(line)
|
|
||||||
with open(registry_path, 'w', encoding='utf-8') as f:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
||||||
try:
|
|
||||||
f.writelines(lines_to_keep)
|
|
||||||
self._cache.discard(file_path)
|
|
||||||
self._cache.discard(Path(file_path).name)
|
|
||||||
finally:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error removing from processed files registry: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Clear the entire registry"""
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
try:
|
|
||||||
if registry_path.exists():
|
|
||||||
registry_path.unlink()
|
|
||||||
self._cache.clear()
|
|
||||||
self._cache_time = None
|
|
||||||
self.logger.info("Processed files registry cleared")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error clearing processed files registry: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_all(self) -> Set[str]:
|
|
||||||
"""Get all processed files"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._cache.copy()
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
"""Get count of processed files"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return len(self._cache)
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
processed_registry = ProcessedRegistry()
|
|
||||||
436
codex.md
Normal file
436
codex.md
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
# CODEX.md
|
||||||
|
|
||||||
|
Guia maestra para producir resúmenes académicos de alta calidad en HTML/PDF con coherencia conceptual, densidad útil y gráficos claros.
|
||||||
|
|
||||||
|
Esta guía está diseñada para cualquier materia:
|
||||||
|
- economía
|
||||||
|
- física
|
||||||
|
- historia
|
||||||
|
- biología
|
||||||
|
- derecho
|
||||||
|
- ingeniería
|
||||||
|
- filosofía
|
||||||
|
- otras
|
||||||
|
|
||||||
|
Objetivo: que cualquier IA pueda replicar un estándar profesional de resumen de estudio, no solo “texto lindo”.
|
||||||
|
|
||||||
|
## 1) Principios de diseño (no negociables)
|
||||||
|
|
||||||
|
1. Claridad causal
|
||||||
|
- Cada afirmación importante debe responder: qué cambia, por qué cambia y qué consecuencia produce.
|
||||||
|
- Evitar listas de definiciones sin mecanismo.
|
||||||
|
|
||||||
|
2. Legibilidad real para estudiar
|
||||||
|
- El documento debe poder leerse en bloques de 15-25 minutos.
|
||||||
|
- Los títulos deben guiar la estrategia de estudio, no solo “decorar”.
|
||||||
|
|
||||||
|
3. Densidad informativa sana
|
||||||
|
- Evitar páginas vacías o con 2 líneas.
|
||||||
|
- Evitar “paredes de texto” sin respiración visual.
|
||||||
|
|
||||||
|
4. Coherencia visual
|
||||||
|
- Mismo sistema de colores, etiquetas y jerarquía tipográfica en todo el documento.
|
||||||
|
- En gráficos, usar siempre convenciones constantes (ejes, colores, flechas, leyendas).
|
||||||
|
|
||||||
|
5. Verificación pre y post PDF
|
||||||
|
- Revisar HTML antes de exportar.
|
||||||
|
- Revisar PDF final (paginado, cortes, legibilidad, textos residuales tipo `file://`).
|
||||||
|
|
||||||
|
## 2) Flujo de trabajo general (end-to-end)
|
||||||
|
|
||||||
|
1. Ingesta de fuentes
|
||||||
|
- Reunir transcripción (`.txt`), material bibliográfico (`.pdf`) y guía de cátedra si existe.
|
||||||
|
- Detectar ruido de transcripción: muletillas, repetición, errores de OCR.
|
||||||
|
|
||||||
|
2. Extracción semántica
|
||||||
|
- Separar: conceptos núcleo, definiciones, procedimientos, ejemplos de clase, errores típicos de examen.
|
||||||
|
- Marcar explícitamente qué partes son “base” y cuáles son “expansión didáctica”.
|
||||||
|
|
||||||
|
3. Diseño de estructura
|
||||||
|
- Construir índice con progresión lógica (de fundamentos a aplicación).
|
||||||
|
- Incluir sí o sí: casos resueltos, checklist, autoevaluación.
|
||||||
|
|
||||||
|
4. Redacción por capas
|
||||||
|
- Capa 1: idea central de sección en 1-2 párrafos.
|
||||||
|
- Capa 2: tabla o esquema operativo.
|
||||||
|
- Capa 3: caja de “importante” o “error frecuente”.
|
||||||
|
- Capa 4: caso aplicado.
|
||||||
|
|
||||||
|
5. Maquetación HTML
|
||||||
|
- Usar componentes consistentes: portada, secciones, tablas, cajas, gráficos SVG, banco de casos.
|
||||||
|
- Preparar para impresión A4 desde el inicio.
|
||||||
|
|
||||||
|
6. Exportación PDF
|
||||||
|
- Exportar con motor headless estable (Chromium recomendado).
|
||||||
|
- Desactivar headers/footers automáticos del navegador.
|
||||||
|
|
||||||
|
7. QA final
|
||||||
|
- Verificar páginas “casi vacías”.
|
||||||
|
- Verificar cortes feos (tabla partida, título huérfano, caja cortada).
|
||||||
|
- Verificar coherencia de gráficos y legibilidad de flechas/etiquetas.
|
||||||
|
|
||||||
|
## 3) Estructura recomendada del resumen (plantilla universal)
|
||||||
|
|
||||||
|
1. Portada
|
||||||
|
- Materia + clase + tema central.
|
||||||
|
- Objetivo de estudio de esa clase.
|
||||||
|
- Mapa rápido (ruta de lectura en una línea).
|
||||||
|
|
||||||
|
2. Índice
|
||||||
|
- 6 a 12 bloques como máximo por clase regular.
|
||||||
|
- Nombres de sección orientados a acción.
|
||||||
|
|
||||||
|
3. Desarrollo conceptual
|
||||||
|
- Definiciones esenciales.
|
||||||
|
- Mecanismos principales.
|
||||||
|
- Tabla de términos operativos.
|
||||||
|
|
||||||
|
4. Aplicación
|
||||||
|
- Casos simples.
|
||||||
|
- Casos combinados o ambiguos.
|
||||||
|
- Errores frecuentes.
|
||||||
|
|
||||||
|
5. Entrenamiento examen
|
||||||
|
- Simulación de parcial (preguntas).
|
||||||
|
- Respuestas modelo cortas.
|
||||||
|
- Preguntas de desarrollo.
|
||||||
|
|
||||||
|
6. Cierre
|
||||||
|
- Checklist final.
|
||||||
|
- Tarjetas de repaso.
|
||||||
|
- Mini glosario.
|
||||||
|
|
||||||
|
## 4) Reglas de redacción (para cualquier disciplina)
|
||||||
|
|
||||||
|
1. Escribir en modo operativo
|
||||||
|
- En vez de “la elasticidad es importante”, escribir: “si |E| > 1, un aumento de precio reduce recaudación”.
|
||||||
|
|
||||||
|
2. Separar descripción de inferencia
|
||||||
|
- `Descripción`: qué se observa.
|
||||||
|
- `Inferencia`: qué significa y bajo qué condiciones.
|
||||||
|
|
||||||
|
3. Evitar ambigüedad de sujeto
|
||||||
|
- No usar “esto cambia aquello” sin especificar variable.
|
||||||
|
|
||||||
|
4. Definir límites
|
||||||
|
- Toda regla importante debe incluir cuándo no aplica.
|
||||||
|
|
||||||
|
5. Frases de cierre por sección
|
||||||
|
- Cerrar sección con una frase “si te preguntan X, responde Y con Z mecanismo”.
|
||||||
|
|
||||||
|
## 5) Componentes visuales y semánticos
|
||||||
|
|
||||||
|
### 5.1 Cajas semánticas
|
||||||
|
|
||||||
|
- `.definicion`
|
||||||
|
- Uso: concepto técnico o regla formal.
|
||||||
|
- Debe responder “qué es”.
|
||||||
|
|
||||||
|
- `.importante`
|
||||||
|
- Uso: advertencia, límite, error típico.
|
||||||
|
- Debe responder “qué no confundir”.
|
||||||
|
|
||||||
|
- `.ejemplo`
|
||||||
|
- Uso: traducción a caso concreto.
|
||||||
|
- Debe responder “cómo se aplica”.
|
||||||
|
|
||||||
|
### 5.2 Tablas
|
||||||
|
|
||||||
|
Usar tablas para:
|
||||||
|
- comparaciones (A vs B)
|
||||||
|
- pasos de procedimiento
|
||||||
|
- matriz de cambios
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- encabezado corto y explícito
|
||||||
|
- 3-5 columnas máximo
|
||||||
|
- celdas con frases breves, no párrafos largos
|
||||||
|
|
||||||
|
### 5.3 Gráficos (estándar universal)
|
||||||
|
|
||||||
|
Principio: todo gráfico debe poder leerse en 10 segundos.
|
||||||
|
|
||||||
|
Checklist mínimo por gráfico:
|
||||||
|
- ejes rotulados
|
||||||
|
- elementos con nombres visibles
|
||||||
|
- leyenda de colores
|
||||||
|
- flecha/sentido de cambio claro
|
||||||
|
- caption con interpretación
|
||||||
|
- bloque “Lectura del gráfico” con mecanismo en texto
|
||||||
|
|
||||||
|
## 6) Guía técnica de gráficos por tipo de materia
|
||||||
|
|
||||||
|
### 6.1 Economía
|
||||||
|
|
||||||
|
Formato base:
|
||||||
|
- Eje vertical: precio/salario/tasa
|
||||||
|
- Eje horizontal: cantidad/trabajo/fondos
|
||||||
|
- Curva inicial: gris
|
||||||
|
- Curva nueva: color principal (rojo demanda, verde oferta)
|
||||||
|
- Flecha: azul oscuro gruesa
|
||||||
|
- Resultado: texto final con dirección de equilibrio
|
||||||
|
|
||||||
|
Lectura mínima obligatoria:
|
||||||
|
- `Curva implicada`
|
||||||
|
- `Mecanismo`
|
||||||
|
- `Resultado`
|
||||||
|
|
||||||
|
### 6.2 Física
|
||||||
|
|
||||||
|
Gráficos típicos:
|
||||||
|
- posición-tiempo, velocidad-tiempo, aceleración-tiempo
|
||||||
|
- energía potencial vs coordenada
|
||||||
|
- circuitos (I-V)
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- incluir unidades SI en ejes
|
||||||
|
- marcar pendiente/área cuando tenga significado físico
|
||||||
|
- incluir condición inicial/final
|
||||||
|
- indicar régimen (lineal/no lineal)
|
||||||
|
|
||||||
|
### 6.3 Historia
|
||||||
|
|
||||||
|
Gráficos útiles:
|
||||||
|
- línea de tiempo con hitos
|
||||||
|
- mapa de actores (Estado, grupos, alianzas)
|
||||||
|
- matriz causa-evento-consecuencia
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- separar causas estructurales de detonantes
|
||||||
|
- distinguir corto vs largo plazo
|
||||||
|
- marcar continuidad vs ruptura
|
||||||
|
|
||||||
|
### 6.4 Biología
|
||||||
|
|
||||||
|
Gráficos útiles:
|
||||||
|
- rutas (metabólicas, señalización)
|
||||||
|
- taxonomías jerárquicas
|
||||||
|
- tablas comparativas de procesos
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- nombrar niveles de organización
|
||||||
|
- explicitar entrada/salida de cada proceso
|
||||||
|
- marcar regulación positiva/negativa
|
||||||
|
|
||||||
|
### 6.5 Derecho
|
||||||
|
|
||||||
|
Diagramas útiles:
|
||||||
|
- flujo procedimental
|
||||||
|
- jerarquía normativa
|
||||||
|
- mapa de requisitos y excepciones
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- identificar fuente normativa
|
||||||
|
- separar regla general y excepción
|
||||||
|
- incluir condición de aplicación
|
||||||
|
|
||||||
|
## 7) Estándar CSS recomendado (impresión A4)
|
||||||
|
|
||||||
|
Reglas de impresión:
|
||||||
|
- `@page size: A4`
|
||||||
|
- márgenes 1.5 a 2.0 cm
|
||||||
|
- tipografía serif para cuerpo (Georgia/Times)
|
||||||
|
- tamaño base 10.8-11.2 pt
|
||||||
|
|
||||||
|
Control de cortes:
|
||||||
|
- evitar `break-inside: avoid` global indiscriminado
|
||||||
|
- aplicar `break-inside: avoid` solo en:
|
||||||
|
- tablas
|
||||||
|
- cajas críticas
|
||||||
|
- tarjetas de casos
|
||||||
|
|
||||||
|
Evitar páginas en blanco:
|
||||||
|
- no forzar `page-break-before` salvo secciones pesadas (ej: banco de casos)
|
||||||
|
- si un título queda solo al final de página, ajustar bloques previos o mover sección completa
|
||||||
|
|
||||||
|
## 8) Exportación PDF robusta
|
||||||
|
|
||||||
|
Comando recomendado:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chromium \
|
||||||
|
--headless \
|
||||||
|
--disable-gpu \
|
||||||
|
--no-sandbox \
|
||||||
|
--no-pdf-header-footer \
|
||||||
|
--print-to-pdf="salida.pdf" \
|
||||||
|
"file:///ruta/entrada.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notas:
|
||||||
|
- `--no-pdf-header-footer` evita contaminación con `file://` en pie.
|
||||||
|
- Si aparecen rutas en PDF, revisar opciones de impresión primero.
|
||||||
|
|
||||||
|
## 9) QA automático mínimo
|
||||||
|
|
||||||
|
Herramientas:
|
||||||
|
- `pdfinfo`: cantidad de páginas
|
||||||
|
- `pdftotext`: extracción y búsqueda de basura textual
|
||||||
|
- `rg`: detección rápida de patrones no deseados
|
||||||
|
|
||||||
|
Controles:
|
||||||
|
1. Páginas casi vacías
|
||||||
|
- detectar páginas con bajo conteo de caracteres
|
||||||
|
|
||||||
|
2. Referencias no deseadas
|
||||||
|
- buscar `file://`, `.txt`, `.pdf` si el usuario pidió ocultarlas
|
||||||
|
|
||||||
|
3. Coherencia semántica
|
||||||
|
- cada gráfico debe tener caption y lectura textual
|
||||||
|
|
||||||
|
4. Integridad visual
|
||||||
|
- no cortar tablas ni cajas
|
||||||
|
- no superponer flechas con etiquetas principales
|
||||||
|
|
||||||
|
## 10) Criterios de calidad (rúbrica 0-5)
|
||||||
|
|
||||||
|
1. Precisión conceptual
|
||||||
|
- 0: errores de concepto graves
|
||||||
|
- 5: conceptos correctos y bien delimitados
|
||||||
|
|
||||||
|
2. Coherencia causal
|
||||||
|
- 0: listado sin lógica
|
||||||
|
- 5: mecanismo explícito en cada bloque
|
||||||
|
|
||||||
|
3. Utilidad para examen
|
||||||
|
- 0: no entrenable
|
||||||
|
- 5: casos + respuestas + checklist
|
||||||
|
|
||||||
|
4. Calidad visual
|
||||||
|
- 0: ilegible o inconsistente
|
||||||
|
- 5: limpio, consistente, imprimible
|
||||||
|
|
||||||
|
5. Control técnico
|
||||||
|
- 0: PDF defectuoso
|
||||||
|
- 5: sin residuos, sin páginas vacías, sin cortes feos
|
||||||
|
|
||||||
|
## 11) Reglas para banco de casos
|
||||||
|
|
||||||
|
Cada tarjeta de caso debe contener:
|
||||||
|
- título del caso
|
||||||
|
- por qué cambia
|
||||||
|
- curva/variable implicada
|
||||||
|
- mecanismo causal
|
||||||
|
- gráfico coherente
|
||||||
|
- resultado final
|
||||||
|
|
||||||
|
No aceptar tarjetas con:
|
||||||
|
- solo flechas
|
||||||
|
- gráfico sin explicación
|
||||||
|
- explicación sin variable concreta
|
||||||
|
|
||||||
|
## 12) Reglas para materias cuantitativas
|
||||||
|
|
||||||
|
Agregar siempre:
|
||||||
|
- fórmula núcleo
|
||||||
|
- interpretación económica/física/estadística de cada término
|
||||||
|
- ejemplo numérico mínimo
|
||||||
|
- error típico de cálculo
|
||||||
|
|
||||||
|
Cuando haya derivaciones:
|
||||||
|
- no mostrar álgebra larga si no agrega aprendizaje
|
||||||
|
- priorizar: qué representa, cuándo usarla, cómo interpretar signo/magnitud
|
||||||
|
|
||||||
|
## 13) Reglas para materias cualitativas
|
||||||
|
|
||||||
|
Agregar siempre:
|
||||||
|
- periodización o estructura argumental
|
||||||
|
- actores, intereses, instituciones
|
||||||
|
- relación causa-contexto-consecuencia
|
||||||
|
- contraste entre 2 interpretaciones
|
||||||
|
|
||||||
|
Evitar:
|
||||||
|
- relato cronológico sin tesis
|
||||||
|
- opiniones sin anclaje conceptual
|
||||||
|
|
||||||
|
## 14) Estrategia anti-ruido de transcripción
|
||||||
|
|
||||||
|
Cuando la fuente es clase oral transcripta:
|
||||||
|
- limpiar muletillas y repeticiones
|
||||||
|
- preservar ejemplos de cátedra que aclaren examen
|
||||||
|
- reconstruir frases incompletas manteniendo sentido
|
||||||
|
- marcar inferencias cuando la transcripción es ambigua
|
||||||
|
|
||||||
|
## 15) Política de trazabilidad (sin contaminar el PDF)
|
||||||
|
|
||||||
|
Internamente:
|
||||||
|
- guardar scripts de generación y QA
|
||||||
|
- versionar cambios de estructura y estilo
|
||||||
|
|
||||||
|
En PDF final:
|
||||||
|
- no imprimir rutas locales
|
||||||
|
- no imprimir referencias de archivos si el usuario lo pidió
|
||||||
|
- no insertar notas técnicas irrelevantes para estudio
|
||||||
|
|
||||||
|
## 16) Plantilla de prompts para IA (genérica)
|
||||||
|
|
||||||
|
Prompt base:
|
||||||
|
- “Construye un resumen extendido de [materia/tema], orientado a examen, en formato HTML imprimible A4, con:
|
||||||
|
- índice
|
||||||
|
- desarrollo conceptual
|
||||||
|
- tablas operativas
|
||||||
|
- banco de casos con gráficos SVG claros
|
||||||
|
- simulación de examen
|
||||||
|
- checklist final
|
||||||
|
- sin referencias a rutas de archivos en el PDF.”
|
||||||
|
|
||||||
|
Prompt de QA:
|
||||||
|
- “Audita este HTML/PDF buscando:
|
||||||
|
- páginas con bajo contenido
|
||||||
|
- títulos huérfanos
|
||||||
|
- gráficos sin contexto
|
||||||
|
- etiquetas ilegibles
|
||||||
|
- texto basura (file://, rutas)
|
||||||
|
y propone correcciones puntuales.”
|
||||||
|
|
||||||
|
## 17) Errores frecuentes de IA y correcciones
|
||||||
|
|
||||||
|
1. Error: demasiado resumen, poca utilidad
|
||||||
|
- Corrección: añadir banco de casos y respuestas modelo.
|
||||||
|
|
||||||
|
2. Error: gráficos bonitos pero ambiguos
|
||||||
|
- Corrección: incluir leyenda, curva implicada y mecanismo textual.
|
||||||
|
|
||||||
|
3. Error: demasiados saltos de página
|
||||||
|
- Corrección: reducir `page-break` forzado y reequilibrar bloques.
|
||||||
|
|
||||||
|
4. Error: repite teoría del libro sin foco de examen
|
||||||
|
- Corrección: priorizar preguntas-tipo y decisiones de resolución.
|
||||||
|
|
||||||
|
5. Error: no distingue hechos de inferencias
|
||||||
|
- Corrección: separar “dato/definición” de “interpretación/aplicación”.
|
||||||
|
|
||||||
|
## 18) Checklist final antes de entregar
|
||||||
|
|
||||||
|
Checklist editorial:
|
||||||
|
- índice consistente con secciones
|
||||||
|
- numeración correcta
|
||||||
|
- no hay contradicciones internas
|
||||||
|
|
||||||
|
Checklist visual:
|
||||||
|
- gráficos legibles al 100%
|
||||||
|
- flechas claras
|
||||||
|
- etiquetas no montadas
|
||||||
|
|
||||||
|
Checklist técnico:
|
||||||
|
- PDF abre y pagina bien
|
||||||
|
- no hay `file://`
|
||||||
|
- no hay páginas casi vacías
|
||||||
|
|
||||||
|
Checklist pedagógico:
|
||||||
|
- hay práctica de examen
|
||||||
|
- hay respuestas modelo
|
||||||
|
- hay errores frecuentes
|
||||||
|
- hay cierre operativo
|
||||||
|
|
||||||
|
## 19) Meta-estándar esperado
|
||||||
|
|
||||||
|
Un resumen de “nivel alto” no es el más largo.
|
||||||
|
Es el que logra simultáneamente:
|
||||||
|
- comprensión conceptual
|
||||||
|
- capacidad de resolver ejercicios
|
||||||
|
- lectura rápida y confiable antes del examen
|
||||||
|
- salida PDF limpia y estable
|
||||||
|
|
||||||
|
Si falta uno de esos cuatro, el resumen está incompleto.
|
||||||
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
"""
|
"""Export de configuración."""
|
||||||
Configuration package for CBCFacil
|
from .settings import settings, Settings
|
||||||
"""
|
|
||||||
|
|
||||||
from .settings import settings
|
__all__ = ["settings", "Settings"]
|
||||||
from .validators import validate_environment
|
|
||||||
|
|
||||||
__all__ = ['settings', 'validate_environment']
|
|
||||||
|
|||||||
@@ -1,211 +1,78 @@
|
|||||||
"""
|
"""
|
||||||
Centralized configuration management for CBCFacil
|
Configuración centralizada de la aplicación.
|
||||||
|
Carga variables de entorno desde archivo .env
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Set, Union
|
from typing import Optional
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Cargar variables de entorno
|
||||||
class ConfigurationError(Exception):
|
load_dotenv()
|
||||||
"""Raised when configuration is invalid"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
"""Application settings loaded from environment variables"""
|
"""Configuración centralizada de la aplicación."""
|
||||||
|
|
||||||
# Application
|
|
||||||
APP_NAME: str = "CBCFacil"
|
|
||||||
APP_VERSION: str = "8.0"
|
|
||||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
|
||||||
|
|
||||||
# Nextcloud/WebDAV Configuration
|
# Nextcloud/WebDAV
|
||||||
NEXTCLOUD_URL: str = os.getenv("NEXTCLOUD_URL", "")
|
NEXTCLOUD_URL: str = os.getenv("NEXTCLOUD_URL", "")
|
||||||
NEXTCLOUD_USER: str = os.getenv("NEXTCLOUD_USER", "")
|
NEXTCLOUD_USER: str = os.getenv("NEXTCLOUD_USER", "")
|
||||||
NEXTCLOUD_PASSWORD: str = os.getenv("NEXTCLOUD_PASSWORD", "")
|
NEXTCLOUD_PASSWORD: str = os.getenv("NEXTCLOUD_PASSWORD", "")
|
||||||
WEBDAV_ENDPOINT: str = NEXTCLOUD_URL
|
|
||||||
|
|
||||||
# Remote folders
|
# Telegram (opcional)
|
||||||
REMOTE_AUDIOS_FOLDER: str = "Audios"
|
|
||||||
REMOTE_DOCX_AUDIO_FOLDER: str = "Documentos"
|
|
||||||
REMOTE_PDF_FOLDER: str = "Pdf"
|
|
||||||
REMOTE_TXT_FOLDER: str = "Textos"
|
|
||||||
RESUMENES_FOLDER: str = "Resumenes"
|
|
||||||
DOCX_FOLDER: str = "Documentos"
|
|
||||||
|
|
||||||
# Local paths
|
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
|
||||||
LOCAL_STATE_DIR: str = os.getenv("LOCAL_STATE_DIR", str(BASE_DIR))
|
|
||||||
LOCAL_DOWNLOADS_PATH: Path = BASE_DIR / "downloads"
|
|
||||||
LOCAL_RESUMENES: Path = LOCAL_DOWNLOADS_PATH
|
|
||||||
LOCAL_DOCX: Path = BASE_DIR / "resumenes_docx"
|
|
||||||
|
|
||||||
# Processing
|
|
||||||
POLL_INTERVAL: int = int(os.getenv("POLL_INTERVAL", "5"))
|
|
||||||
HTTP_TIMEOUT: int = int(os.getenv("HTTP_TIMEOUT", "30"))
|
|
||||||
WEBDAV_MAX_RETRIES: int = int(os.getenv("WEBDAV_MAX_RETRIES", "3"))
|
|
||||||
DOWNLOAD_CHUNK_SIZE: int = int(os.getenv("DOWNLOAD_CHUNK_SIZE", "65536")) # 64KB for better performance
|
|
||||||
MAX_FILENAME_LENGTH: int = int(os.getenv("MAX_FILENAME_LENGTH", "80"))
|
|
||||||
MAX_FILENAME_BASE_LENGTH: int = int(os.getenv("MAX_FILENAME_BASE_LENGTH", "40"))
|
|
||||||
MAX_FILENAME_TOPICS_LENGTH: int = int(os.getenv("MAX_FILENAME_TOPICS_LENGTH", "20"))
|
|
||||||
|
|
||||||
# File extensions
|
|
||||||
AUDIO_EXTENSIONS: Set[str] = {".mp3", ".wav", ".m4a", ".ogg", ".aac"}
|
|
||||||
PDF_EXTENSIONS: Set[str] = {".pdf"}
|
|
||||||
TXT_EXTENSIONS: Set[str] = {".txt"}
|
|
||||||
|
|
||||||
# AI Providers
|
|
||||||
ZAI_BASE_URL: str = os.getenv("ZAI_BASE_URL", "https://api.z.ai/api/anthropic")
|
|
||||||
ZAI_DEFAULT_MODEL: str = os.getenv("ZAI_MODEL", "glm-4.6")
|
|
||||||
ZAI_AUTH_TOKEN: Optional[str] = os.getenv("ANTHROPIC_AUTH_TOKEN") or os.getenv("ZAI_AUTH_TOKEN", "")
|
|
||||||
|
|
||||||
# Gemini
|
|
||||||
GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
|
|
||||||
GEMINI_FLASH_MODEL: str = os.getenv("GEMINI_FLASH_MODEL", "gemini-2.5-flash")
|
|
||||||
GEMINI_PRO_MODEL: str = os.getenv("GEMINI_PRO_MODEL", "gemini-1.5-pro")
|
|
||||||
|
|
||||||
# CLI paths
|
|
||||||
GEMINI_CLI_PATH: Optional[str] = os.getenv("GEMINI_CLI_PATH")
|
|
||||||
CLAUDE_CLI_PATH: Optional[str] = os.getenv("CLAUDE_CLI_PATH")
|
|
||||||
|
|
||||||
# Telegram
|
|
||||||
TELEGRAM_TOKEN: Optional[str] = os.getenv("TELEGRAM_TOKEN")
|
TELEGRAM_TOKEN: Optional[str] = os.getenv("TELEGRAM_TOKEN")
|
||||||
TELEGRAM_CHAT_ID: Optional[str] = os.getenv("TELEGRAM_CHAT_ID")
|
TELEGRAM_CHAT_ID: Optional[str] = os.getenv("TELEGRAM_CHAT_ID")
|
||||||
|
|
||||||
# PDF Processing Configuration
|
# AI Providers (opcional)
|
||||||
CPU_COUNT: int = os.cpu_count() or 1
|
GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
|
||||||
PDF_MAX_PAGES_PER_CHUNK: int = int(os.getenv("PDF_MAX_PAGES_PER_CHUNK", "2"))
|
DEEPINFRA_API_KEY: Optional[str] = os.getenv("DEEPINFRA_API_KEY")
|
||||||
PDF_DPI: int = int(os.getenv("PDF_DPI", "200"))
|
ANTHROPIC_AUTH_TOKEN: Optional[str] = os.getenv("ANTHROPIC_AUTH_TOKEN")
|
||||||
PDF_RENDER_THREAD_COUNT: int = int(os.getenv("PDF_RENDER_THREAD_COUNT", str(min(4, CPU_COUNT))))
|
ANTHROPIC_BASE_URL: Optional[str] = os.getenv("ANTHROPIC_BASE_URL")
|
||||||
PDF_BATCH_SIZE: int = int(os.getenv("PDF_BATCH_SIZE", "2"))
|
ANTHROPIC_MODEL: str = os.getenv("ANTHROPIC_MODEL", "glm-4.7")
|
||||||
PDF_TROCR_MAX_BATCH: int = int(os.getenv("PDF_TROCR_MAX_BATCH", str(PDF_BATCH_SIZE)))
|
|
||||||
PDF_TESSERACT_THREADS: int = int(os.getenv("PDF_TESSERACT_THREADS", str(max(1, min(2, max(1, CPU_COUNT // 3))))))
|
|
||||||
PDF_PREPROCESS_THREADS: int = int(os.getenv("PDF_PREPROCESS_THREADS", str(PDF_TESSERACT_THREADS)))
|
|
||||||
PDF_TEXT_DETECTION_MIN_RATIO: float = float(os.getenv("PDF_TEXT_DETECTION_MIN_RATIO", "0.6"))
|
|
||||||
PDF_TEXT_DETECTION_MIN_AVG_CHARS: int = int(os.getenv("PDF_TEXT_DETECTION_MIN_AVG_CHARS", "120"))
|
|
||||||
|
|
||||||
# Error handling
|
# Notion (opcional)
|
||||||
ERROR_THROTTLE_SECONDS: int = int(os.getenv("ERROR_THROTTLE_SECONDS", "600"))
|
NOTION_API: Optional[str] = os.getenv("NOTION_API")
|
||||||
|
NOTION_DATABASE_ID: Optional[str] = os.getenv("NOTION_DATABASE_ID")
|
||||||
# GPU/VRAM Management
|
|
||||||
MODEL_TIMEOUT_SECONDS: int = int(os.getenv("MODEL_TIMEOUT_SECONDS", "300"))
|
|
||||||
CUDA_VISIBLE_DEVICES: str = os.getenv("CUDA_VISIBLE_DEVICES", "all")
|
|
||||||
PYTORCH_CUDA_ALLOC_CONF: str = os.getenv("PYTORCH_CUDA_ALLOC_CONF", "max_split_size_mb:512")
|
|
||||||
|
|
||||||
# GPU Detection (auto, nvidia, amd, cpu)
|
|
||||||
GPU_PREFERENCE: str = os.getenv("GPU_PREFERENCE", "auto")
|
|
||||||
# AMD ROCm HSA override for RX 6000 series (gfx1030)
|
|
||||||
HSA_OVERRIDE_GFX_VERSION: str = os.getenv("HSA_OVERRIDE_GFX_VERSION", "10.3.0")
|
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
DASHBOARD_SECRET_KEY: str = os.getenv("DASHBOARD_SECRET_KEY", "")
|
|
||||||
DASHBOARD_PORT: int = int(os.getenv("DASHBOARD_PORT", "5000"))
|
|
||||||
DASHBOARD_HOST: str = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
DASHBOARD_HOST: str = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||||
|
DASHBOARD_PORT: int = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||||
|
DASHBOARD_SECRET_KEY: Optional[str] = os.getenv("DASHBOARD_SECRET_KEY")
|
||||||
|
|
||||||
|
# Rutas locales
|
||||||
|
BASE_DIR: Path = Path(__file__).parent.parent
|
||||||
|
DOWNLOADS_DIR: Path = BASE_DIR / "downloads"
|
||||||
|
TRANSCRIPTIONS_DIR: Path = BASE_DIR / "transcriptions"
|
||||||
|
|
||||||
|
# Whisper
|
||||||
|
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "medium")
|
||||||
|
WHISPER_DEVICE: str = os.getenv("WHISPER_DEVICE", "auto") # auto, cuda, cpu
|
||||||
|
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "es")
|
||||||
|
WHISPER_AUTO_UNLOAD_SECONDS: int = int(os.getenv("WHISPER_AUTO_UNLOAD_SECONDS", "300")) # 5 minutos
|
||||||
|
|
||||||
|
# Configuración de polling
|
||||||
|
POLL_INTERVAL: int = int(os.getenv("POLL_INTERVAL", "30")) # segundos
|
||||||
|
WATCHED_REMOTE_PATH: str = os.getenv("WATCHED_REMOTE_PATH", "/Audios")
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||||
LOG_FILE: Optional[str] = os.getenv("LOG_FILE")
|
LOG_FILE: Optional[str] = os.getenv("LOG_FILE")
|
||||||
|
|
||||||
# Threading optimization
|
|
||||||
OMP_NUM_THREADS: int = int(os.getenv("OMP_NUM_THREADS", "4"))
|
|
||||||
MKL_NUM_THREADS: int = int(os.getenv("MKL_NUM_THREADS", "4"))
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# PROPERTIES WITH VALIDATION
|
|
||||||
# ========================================================================
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_production(self) -> bool:
|
|
||||||
"""Check if running in production mode"""
|
|
||||||
return not self.DEBUG
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_webdav_config(self) -> bool:
|
def has_webdav_config(self) -> bool:
|
||||||
"""Check if WebDAV credentials are configured"""
|
"""Verifica si hay configuración de WebDAV."""
|
||||||
return all([self.NEXTCLOUD_URL, self.NEXTCLOUD_USER, self.NEXTCLOUD_PASSWORD])
|
return bool(self.NEXTCLOUD_URL and self.NEXTCLOUD_USER and self.NEXTCLOUD_PASSWORD)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_ai_config(self) -> bool:
|
def has_telegram_config(self) -> bool:
|
||||||
"""Check if AI providers are configured"""
|
"""Verifica si hay configuración de Telegram."""
|
||||||
return any([
|
|
||||||
self.ZAI_AUTH_TOKEN,
|
|
||||||
self.GEMINI_API_KEY,
|
|
||||||
self.CLAUDE_CLI_PATH,
|
|
||||||
self.GEMINI_CLI_PATH
|
|
||||||
])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def processed_files_path(self) -> Path:
|
|
||||||
"""Get the path to the processed files registry"""
|
|
||||||
return Path(os.getenv("PROCESSED_FILES_PATH", str(Path(self.LOCAL_STATE_DIR) / "processed_files.txt")))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def nextcloud_url(self) -> str:
|
|
||||||
"""Get Nextcloud URL with validation"""
|
|
||||||
if not self.NEXTCLOUD_URL and self.is_production:
|
|
||||||
raise ConfigurationError("NEXTCLOUD_URL is required in production mode")
|
|
||||||
return self.NEXTCLOUD_URL
|
|
||||||
|
|
||||||
@property
|
|
||||||
def nextcloud_user(self) -> str:
|
|
||||||
"""Get Nextcloud username with validation"""
|
|
||||||
if not self.NEXTCLOUD_USER and self.is_production:
|
|
||||||
raise ConfigurationError("NEXTCLOUD_USER is required in production mode")
|
|
||||||
return self.NEXTCLOUD_USER
|
|
||||||
|
|
||||||
@property
|
|
||||||
def nextcloud_password(self) -> str:
|
|
||||||
"""Get Nextcloud password with validation"""
|
|
||||||
if not self.NEXTCLOUD_PASSWORD and self.is_production:
|
|
||||||
raise ConfigurationError("NEXTCLOUD_PASSWORD is required in production mode")
|
|
||||||
return self.NEXTCLOUD_PASSWORD
|
|
||||||
|
|
||||||
@property
|
|
||||||
def valid_webdav_config(self) -> bool:
|
|
||||||
"""Validate WebDAV configuration completeness"""
|
|
||||||
try:
|
|
||||||
_ = self.nextcloud_url
|
|
||||||
_ = self.nextcloud_user
|
|
||||||
_ = self.nextcloud_password
|
|
||||||
return True
|
|
||||||
except ConfigurationError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telegram_configured(self) -> bool:
|
|
||||||
"""Check if Telegram is properly configured"""
|
|
||||||
return bool(self.TELEGRAM_TOKEN and self.TELEGRAM_CHAT_ID)
|
return bool(self.TELEGRAM_TOKEN and self.TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_gpu_support(self) -> bool:
|
def is_production(self) -> bool:
|
||||||
"""Check if GPU support is available"""
|
"""Verifica si está en modo producción."""
|
||||||
try:
|
return os.getenv("ENV", "development") == "production"
|
||||||
import torch
|
|
||||||
return torch.cuda.is_available()
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def environment_type(self) -> str:
|
|
||||||
"""Get environment type as string"""
|
|
||||||
return "production" if self.is_production else "development"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config_summary(self) -> dict:
|
|
||||||
"""Get configuration summary for logging"""
|
|
||||||
return {
|
|
||||||
"app_name": self.APP_NAME,
|
|
||||||
"version": self.APP_VERSION,
|
|
||||||
"environment": self.environment_type,
|
|
||||||
"debug": self.DEBUG,
|
|
||||||
"webdav_configured": self.has_webdav_config,
|
|
||||||
"ai_configured": self.has_ai_config,
|
|
||||||
"telegram_configured": self.telegram_configured,
|
|
||||||
"gpu_support": self.has_gpu_support,
|
|
||||||
"cpu_count": self.CPU_COUNT,
|
|
||||||
"poll_interval": self.POLL_INTERVAL
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Create global settings instance
|
# Instancia global de configuración
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
"""
|
|
||||||
Centralized configuration management for CBCFacil
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Set
|
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
|
||||||
"""Application settings loaded from environment variables"""
|
|
||||||
|
|
||||||
# Application
|
|
||||||
APP_NAME: str = "CBCFacil"
|
|
||||||
APP_VERSION: str = "8.0"
|
|
||||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
|
||||||
|
|
||||||
# Nextcloud/WebDAV Configuration
|
|
||||||
NEXTCLOUD_URL: str = os.getenv("NEXTCLOUD_URL", "")
|
|
||||||
NEXTCLOUD_USER: str = os.getenv("NEXTCLOUD_USER", "")
|
|
||||||
NEXTCLOUD_PASSWORD: str = os.getenv("NEXTCLOUD_PASSWORD", "")
|
|
||||||
WEBDAV_ENDPOINT: str = NEXTCLOUD_URL
|
|
||||||
|
|
||||||
# Remote folders
|
|
||||||
REMOTE_AUDIOS_FOLDER: str = "Audios"
|
|
||||||
REMOTE_DOCX_AUDIO_FOLDER: str = "Documentos"
|
|
||||||
REMOTE_PDF_FOLDER: str = "Pdf"
|
|
||||||
REMOTE_TXT_FOLDER: str = "Textos"
|
|
||||||
RESUMENES_FOLDER: str = "Resumenes"
|
|
||||||
DOCX_FOLDER: str = "Documentos"
|
|
||||||
|
|
||||||
# Local paths
|
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
|
||||||
LOCAL_STATE_DIR: str = os.getenv("LOCAL_STATE_DIR", str(BASE_DIR))
|
|
||||||
LOCAL_DOWNLOADS_PATH: Path = BASE_DIR / "downloads"
|
|
||||||
LOCAL_RESUMENES: Path = LOCAL_DOWNLOADS_PATH
|
|
||||||
LOCAL_DOCX: Path = BASE_DIR / "resumenes_docx"
|
|
||||||
|
|
||||||
# Processing
|
|
||||||
POLL_INTERVAL: int = int(os.getenv("POLL_INTERVAL", "5"))
|
|
||||||
HTTP_TIMEOUT: int = int(os.getenv("HTTP_TIMEOUT", "30"))
|
|
||||||
WEBDAV_MAX_RETRIES: int = int(os.getenv("WEBDAV_MAX_RETRIES", "3"))
|
|
||||||
DOWNLOAD_CHUNK_SIZE: int = int(os.getenv("DOWNLOAD_CHUNK_SIZE", "65536")) # 64KB for better performance
|
|
||||||
MAX_FILENAME_LENGTH: int = int(os.getenv("MAX_FILENAME_LENGTH", "80"))
|
|
||||||
MAX_FILENAME_BASE_LENGTH: int = int(os.getenv("MAX_FILENAME_BASE_LENGTH", "40"))
|
|
||||||
MAX_FILENAME_TOPICS_LENGTH: int = int(os.getenv("MAX_FILENAME_TOPICS_LENGTH", "20"))
|
|
||||||
|
|
||||||
# File extensions
|
|
||||||
AUDIO_EXTENSIONS: Set[str] = {".mp3", ".wav", ".m4a", ".ogg", ".aac"}
|
|
||||||
PDF_EXTENSIONS: Set[str] = {".pdf"}
|
|
||||||
TXT_EXTENSIONS: Set[str] = {".txt"}
|
|
||||||
|
|
||||||
# AI Providers
|
|
||||||
ZAI_BASE_URL: str = os.getenv("ZAI_BASE_URL", "https://api.z.ai/api/anthropic")
|
|
||||||
ZAI_DEFAULT_MODEL: str = os.getenv("ZAI_MODEL", "glm-4.6")
|
|
||||||
ZAI_AUTH_TOKEN: Optional[str] = os.getenv("ANTHROPIC_AUTH_TOKEN") or os.getenv("ZAI_AUTH_TOKEN", "")
|
|
||||||
|
|
||||||
# Gemini
|
|
||||||
GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
|
|
||||||
GEMINI_FLASH_MODEL: Optional[str] = os.getenv("GEMINI_FLASH_MODEL")
|
|
||||||
GEMINI_PRO_MODEL: Optional[str] = os.getenv("GEMINI_PRO_MODEL")
|
|
||||||
|
|
||||||
# CLI paths
|
|
||||||
GEMINI_CLI_PATH: Optional[str] = os.getenv("GEMINI_CLI_PATH")
|
|
||||||
CLAUDE_CLI_PATH: Optional[str] = os.getenv("CLAUDE_CLI_PATH")
|
|
||||||
|
|
||||||
# Telegram
|
|
||||||
TELEGRAM_TOKEN: Optional[str] = os.getenv("TELEGRAM_TOKEN")
|
|
||||||
TELEGRAM_CHAT_ID: Optional[str] = os.getenv("TELEGRAM_CHAT_ID")
|
|
||||||
|
|
||||||
# PDF Processing Configuration
|
|
||||||
CPU_COUNT: int = os.cpu_count() or 1
|
|
||||||
PDF_MAX_PAGES_PER_CHUNK: int = int(os.getenv("PDF_MAX_PAGES_PER_CHUNK", "2"))
|
|
||||||
PDF_DPI: int = int(os.getenv("PDF_DPI", "200"))
|
|
||||||
PDF_RENDER_THREAD_COUNT: int = int(os.getenv("PDF_RENDER_THREAD_COUNT", str(min(4, CPU_COUNT))))
|
|
||||||
PDF_BATCH_SIZE: int = int(os.getenv("PDF_BATCH_SIZE", "2"))
|
|
||||||
PDF_TROCR_MAX_BATCH: int = int(os.getenv("PDF_TROCR_MAX_BATCH", str(PDF_BATCH_SIZE)))
|
|
||||||
PDF_TESSERACT_THREADS: int = int(os.getenv("PDF_TESSERACT_THREADS", str(max(1, min(2, max(1, CPU_COUNT // 3))))))
|
|
||||||
PDF_PREPROCESS_THREADS: int = int(os.getenv("PDF_PREPROCESS_THREADS", str(PDF_TESSERACT_THREADS)))
|
|
||||||
PDF_TEXT_DETECTION_MIN_RATIO: float = float(os.getenv("PDF_TEXT_DETECTION_MIN_RATIO", "0.6"))
|
|
||||||
PDF_TEXT_DETECTION_MIN_AVG_CHARS: int = int(os.getenv("PDF_TEXT_DETECTION_MIN_AVG_CHARS", "120"))
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
ERROR_THROTTLE_SECONDS: int = int(os.getenv("ERROR_THROTTLE_SECONDS", "600"))
|
|
||||||
|
|
||||||
# GPU/VRAM Management
|
|
||||||
MODEL_TIMEOUT_SECONDS: int = int(os.getenv("MODEL_TIMEOUT_SECONDS", "300"))
|
|
||||||
CUDA_VISIBLE_DEVICES: str = os.getenv("CUDA_VISIBLE_DEVICES", "all")
|
|
||||||
PYTORCH_CUDA_ALLOC_CONF: str = os.getenv("PYTORCH_CUDA_ALLOC_CONF", "max_split_size_mb:512")
|
|
||||||
|
|
||||||
# Dashboard
|
|
||||||
DASHBOARD_SECRET_KEY: str = os.getenv("DASHBOARD_SECRET_KEY", "")
|
|
||||||
DASHBOARD_PORT: int = int(os.getenv("DASHBOARD_PORT", "5000"))
|
|
||||||
DASHBOARD_HOST: str = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
|
||||||
LOG_FILE: Optional[str] = os.getenv("LOG_FILE")
|
|
||||||
|
|
||||||
# Threading optimization
|
|
||||||
OMP_NUM_THREADS: int = int(os.getenv("OMP_NUM_THREADS", "4"))
|
|
||||||
MKL_NUM_THREADS: int = int(os.getenv("MKL_NUM_THREADS", "4"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_production(self) -> bool:
|
|
||||||
"""Check if running in production mode"""
|
|
||||||
return not self.DEBUG
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_webdav_config(self) -> bool:
|
|
||||||
"""Check if WebDAV credentials are configured"""
|
|
||||||
return all([self.NEXTCLOUD_URL, self.NEXTCLOUD_USER, self.NEXTCLOUD_PASSWORD])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_ai_config(self) -> bool:
|
|
||||||
"""Check if AI providers are configured"""
|
|
||||||
return any([
|
|
||||||
self.ZAI_AUTH_TOKEN,
|
|
||||||
self.GEMINI_API_KEY,
|
|
||||||
self.CLAUDE_CLI_PATH,
|
|
||||||
self.GEMINI_CLI_PATH
|
|
||||||
])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def processed_files_path(self) -> Path:
|
|
||||||
"""Get the path to the processed files registry"""
|
|
||||||
return Path(os.getenv("PROCESSED_FILES_PATH", str(Path(self.LOCAL_STATE_DIR) / "processed_files.txt")))
|
|
||||||
|
|
||||||
|
|
||||||
# Create global settings instance
|
|
||||||
settings = Settings()
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""
|
|
||||||
Configuration validators for CBCFacil
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import List, Dict
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationError(Exception):
|
|
||||||
"""Raised when configuration is invalid"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def validate_environment() -> List[str]:
|
|
||||||
"""
|
|
||||||
Validate required environment variables and configuration.
|
|
||||||
Returns a list of warnings/errors.
|
|
||||||
"""
|
|
||||||
from .settings import settings
|
|
||||||
|
|
||||||
warnings = []
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
# Check critical configurations
|
|
||||||
if not settings.has_webdav_config:
|
|
||||||
warnings.append("WebDAV credentials not configured - file sync will not work")
|
|
||||||
|
|
||||||
if not settings.has_ai_config:
|
|
||||||
warnings.append("No AI providers configured - summary generation will not work")
|
|
||||||
|
|
||||||
# Validate API keys format if provided
|
|
||||||
if settings.ZAI_AUTH_TOKEN:
|
|
||||||
if len(settings.ZAI_AUTH_TOKEN) < 10:
|
|
||||||
errors.append("ZAI_AUTH_TOKEN appears to be invalid (too short)")
|
|
||||||
|
|
||||||
if settings.GEMINI_API_KEY:
|
|
||||||
if len(settings.GEMINI_API_KEY) < 20:
|
|
||||||
errors.append("GEMINI_API_KEY appears to be invalid (too short)")
|
|
||||||
|
|
||||||
# Validate dashboard secret
|
|
||||||
if not settings.DASHBOARD_SECRET_KEY:
|
|
||||||
warnings.append("DASHBOARD_SECRET_KEY not set - using default is not recommended for production")
|
|
||||||
|
|
||||||
if settings.DASHBOARD_SECRET_KEY == "dashboard-secret-key-change-in-production":
|
|
||||||
warnings.append("Using default dashboard secret key - please change in production")
|
|
||||||
|
|
||||||
# Check CUDA availability
|
|
||||||
try:
|
|
||||||
import torch
|
|
||||||
if not torch.cuda.is_available():
|
|
||||||
warnings.append("CUDA not available - GPU acceleration will be disabled")
|
|
||||||
except ImportError:
|
|
||||||
warnings.append("PyTorch not installed - GPU acceleration will be disabled")
|
|
||||||
|
|
||||||
# Print warnings
|
|
||||||
for warning in warnings:
|
|
||||||
logging.warning(f"Configuration warning: {warning}")
|
|
||||||
|
|
||||||
# Raise error if critical issues
|
|
||||||
if errors:
|
|
||||||
error_msg = "Configuration errors:\n" + "\n".join(f"- {e}" for e in errors)
|
|
||||||
logging.error(error_msg)
|
|
||||||
raise ConfigurationError(error_msg)
|
|
||||||
|
|
||||||
return warnings
|
|
||||||
@@ -1,23 +1,5 @@
|
|||||||
"""
|
"""Core module exports."""
|
||||||
Core package for CBCFacil
|
from .process_manager import ProcessManager, ProcessState
|
||||||
"""
|
from processors.audio_processor import AudioProcessingError
|
||||||
|
|
||||||
from .exceptions import (
|
__all__ = ["ProcessManager", "ProcessState", "AudioProcessingError"]
|
||||||
ProcessingError,
|
|
||||||
WebDAVError,
|
|
||||||
AIProcessingError,
|
|
||||||
ConfigurationError,
|
|
||||||
FileProcessingError
|
|
||||||
)
|
|
||||||
from .result import Result
|
|
||||||
from .base_service import BaseService
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'ProcessingError',
|
|
||||||
'WebDAVError',
|
|
||||||
'AIProcessingError',
|
|
||||||
'ConfigurationError',
|
|
||||||
'FileProcessingError',
|
|
||||||
'Result',
|
|
||||||
'BaseService'
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: nextcloud_ai_app
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- NVIDIA_VISIBLE_DEVICES=all
|
|
||||||
- CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1
|
|
||||||
volumes:
|
|
||||||
- ./downloads:/app/downloads
|
|
||||||
- ./resumenes_docx:/app/resumenes_docx
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: 1
|
|
||||||
capabilities: [gpu]
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
# Guia de Despliegue - CBCFacil
|
|
||||||
|
|
||||||
Esta guia describe las opciones y procedimientos para desplegar CBCFacil en diferentes entornos.
|
|
||||||
|
|
||||||
## Opciones de Despliegue
|
|
||||||
|
|
||||||
| Metodo | Complejidad | Recomendado para |
|
|
||||||
|--------|-------------|------------------|
|
|
||||||
| Docker Compose | Baja | Desarrollo, Produccion ligera |
|
|
||||||
| Docker Standalone | Media | Produccion con orquestacion |
|
|
||||||
| Virtual Environment | Baja | Desarrollo local |
|
|
||||||
| Kubernetes | Alta | Produccion a escala |
|
|
||||||
|
|
||||||
## Despliegue con Docker Compose
|
|
||||||
|
|
||||||
### Prerrequisitos
|
|
||||||
|
|
||||||
- Docker 24.0+
|
|
||||||
- Docker Compose 2.20+
|
|
||||||
- NVIDIA Container Toolkit (para GPU)
|
|
||||||
|
|
||||||
### Configuracion
|
|
||||||
|
|
||||||
1. Crear archivo de configuracion:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env.production
|
|
||||||
nano .env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Configurar variables sensibles:
|
|
||||||
```bash
|
|
||||||
# .env.production
|
|
||||||
NEXTCLOUD_URL=https://nextcloud.example.com/remote.php/webdav
|
|
||||||
NEXTCLOUD_USER=tu_usuario
|
|
||||||
NEXTCLOUD_PASSWORD=tu_contrasena_segura
|
|
||||||
|
|
||||||
ANTHROPIC_AUTH_TOKEN=sk-ant-...
|
|
||||||
GEMINI_API_KEY=AIza...
|
|
||||||
|
|
||||||
TELEGRAM_TOKEN=bot_token
|
|
||||||
TELEGRAM_CHAT_ID=chat_id
|
|
||||||
|
|
||||||
CUDA_VISIBLE_DEVICES=all
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Verificar docker-compose.yml:
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
cbcfacil:
|
|
||||||
build: .
|
|
||||||
container_name: cbcfacil
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
volumes:
|
|
||||||
- ./downloads:/app/downloads
|
|
||||||
- ./resumenes_docx:/app/resumenes_docx
|
|
||||||
- ./logs:/app/logs
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
|
||||||
- NEXTCLOUD_URL=${NEXTCLOUD_URL}
|
|
||||||
- NEXTCLOUD_USER=${NEXTCLOUD_USER}
|
|
||||||
- NEXTCLOUD_PASSWORD=${NEXTCLOUD_PASSWORD}
|
|
||||||
- ANTHROPIC_AUTH_TOKEN=${ANTHROPIC_AUTH_TOKEN}
|
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
|
||||||
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
|
|
||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
|
||||||
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES}
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL}
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: all
|
|
||||||
capabilities: [gpu]
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Despliegue
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Construir y levantar
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# Ver logs
|
|
||||||
docker compose logs -f cbcfacil
|
|
||||||
|
|
||||||
# Ver estado
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
# Reiniciar
|
|
||||||
docker compose restart cbcfacil
|
|
||||||
|
|
||||||
# Detener
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Actualizacion
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Hacer backup de datos
|
|
||||||
docker cp cbcfacil:/app/data ./backup/data
|
|
||||||
|
|
||||||
# Actualizar imagen
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# Verificar
|
|
||||||
docker compose logs -f cbcfacil
|
|
||||||
```
|
|
||||||
|
|
||||||
## Despliegue con Docker Standalone
|
|
||||||
|
|
||||||
### Construir Imagen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Construir imagen
|
|
||||||
docker build -t cbcfacil:latest .
|
|
||||||
|
|
||||||
# Verificar imagen
|
|
||||||
docker images cbcfacil
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ejecutar Contenedor
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Con GPU
|
|
||||||
docker run -d \
|
|
||||||
--name cbcfacil \
|
|
||||||
--gpus all \
|
|
||||||
-p 5000:5000 \
|
|
||||||
-v $(pwd)/downloads:/app/downloads \
|
|
||||||
-v $(pwd)/resumenes_docx:/app/resumenes_docx \
|
|
||||||
-v $(pwd)/logs:/app/logs \
|
|
||||||
-e NEXTCLOUD_URL=${NEXTCLOUD_URL} \
|
|
||||||
-e NEXTCLOUD_USER=${NEXTCLOUD_USER} \
|
|
||||||
-e NEXTCLOUD_PASSWORD=${NEXTCLOUD_PASSWORD} \
|
|
||||||
-e ANTHROPIC_AUTH_TOKEN=${ANTHROPIC_AUTH_TOKEN} \
|
|
||||||
-e GEMINI_API_KEY=${GEMINI_API_KEY} \
|
|
||||||
cbcfacil:latest
|
|
||||||
|
|
||||||
# Ver logs
|
|
||||||
docker logs -f cbcfacil
|
|
||||||
|
|
||||||
# Detener
|
|
||||||
docker stop cbcfacil && docker rm cbcfacil
|
|
||||||
```
|
|
||||||
|
|
||||||
## Despliegue Local (Virtual Environment)
|
|
||||||
|
|
||||||
### Prerrequisitos
|
|
||||||
|
|
||||||
- Python 3.10+
|
|
||||||
- NVIDIA drivers (opcional)
|
|
||||||
|
|
||||||
### Instalacion
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clonar y entrar al directorio
|
|
||||||
git clone <repo_url>
|
|
||||||
cd cbcfacil
|
|
||||||
|
|
||||||
# Crear entorno virtual
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Instalar dependencias
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Configurar variables de entorno
|
|
||||||
cp .env.example .env.production
|
|
||||||
nano .env.production
|
|
||||||
|
|
||||||
# Crear directorios
|
|
||||||
mkdir -p downloads resumenes_docx logs data
|
|
||||||
|
|
||||||
# Ejecutar
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Como Servicio Systemd
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# /etc/systemd/system/cbcfacil.service
|
|
||||||
[Unit]
|
|
||||||
Description=CBCFacil AI Service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=cbcfacil
|
|
||||||
WorkingDirectory=/opt/cbcfacil
|
|
||||||
Environment="PATH=/opt/cbcfacil/.venv/bin"
|
|
||||||
EnvironmentFile=/opt/cbcfacil/.env.production
|
|
||||||
ExecStart=/opt/cbcfacil/.venv/bin/python main.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=cbcfacil
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Instalar servicio
|
|
||||||
sudo cp cbcfacil.service /etc/systemd/system/
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable cbcfacil
|
|
||||||
sudo systemctl start cbcfacil
|
|
||||||
|
|
||||||
# Verificar estado
|
|
||||||
sudo systemctl status cbcfacil
|
|
||||||
|
|
||||||
# Ver logs
|
|
||||||
journalctl -u cbcfacil -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuracion de Produccion
|
|
||||||
|
|
||||||
### Variables de Entorno Criticas
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Obligatorias
|
|
||||||
NEXTCLOUD_URL=...
|
|
||||||
NEXTCLOUD_USER=...
|
|
||||||
NEXTCLOUD_PASSWORD=...
|
|
||||||
|
|
||||||
# Recomendadas para produccion
|
|
||||||
DEBUG=false
|
|
||||||
LOG_LEVEL=WARNING
|
|
||||||
POLL_INTERVAL=10
|
|
||||||
|
|
||||||
# GPU
|
|
||||||
CUDA_VISIBLE_DEVICES=all
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optimizaciones
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# En .env.production
|
|
||||||
# Reducir polling para menor carga
|
|
||||||
POLL_INTERVAL=10
|
|
||||||
|
|
||||||
# Optimizar memoria GPU
|
|
||||||
PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512
|
|
||||||
|
|
||||||
# Limitar threads
|
|
||||||
OMP_NUM_THREADS=4
|
|
||||||
MKL_NUM_THREADS=4
|
|
||||||
```
|
|
||||||
|
|
||||||
### Seguridad
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Crear usuario dedicado
|
|
||||||
sudo useradd -r -s /bin/false cbcfacil
|
|
||||||
|
|
||||||
# Asignar permisos
|
|
||||||
sudo chown -R cbcfacil:cbcfacil /opt/cbcfacil
|
|
||||||
|
|
||||||
# Proteger archivo de variables
|
|
||||||
sudo chmod 600 /opt/cbcfacil/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoreo
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Endpoint de salud
|
|
||||||
curl http://localhost:5000/health
|
|
||||||
|
|
||||||
# Respuesta esperada
|
|
||||||
{"status": "healthy"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ver logs en tiempo real
|
|
||||||
docker logs -f cbcfacil
|
|
||||||
|
|
||||||
# O con journalctl
|
|
||||||
journalctl -u cbcfacil -f
|
|
||||||
|
|
||||||
# Logs estructurados en JSON (produccion)
|
|
||||||
LOG_LEVEL=WARNING
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metricas
|
|
||||||
|
|
||||||
El sistema expone metricas via API:
|
|
||||||
```bash
|
|
||||||
# Estado del servicio
|
|
||||||
curl http://localhost:5000/api/status
|
|
||||||
```
|
|
||||||
|
|
||||||
## Respaldo y Recuperacion
|
|
||||||
|
|
||||||
### Respaldo de Datos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Directorios a respaldar
|
|
||||||
# - downloads/ (archivos procesados)
|
|
||||||
# - resumenes_docx/ (documentos generados)
|
|
||||||
# - data/ (registros y estados)
|
|
||||||
# - logs/ (logs del sistema)
|
|
||||||
|
|
||||||
# Script de backup
|
|
||||||
#!/bin/bash
|
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BACKUP_DIR=/backup/cbcfacil
|
|
||||||
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
|
|
||||||
tar -czf $BACKUP_DIR/cbcfacil_$DATE.tar.gz \
|
|
||||||
/opt/cbcfacil/downloads \
|
|
||||||
/opt/cbcfacil/resumenes_docx \
|
|
||||||
/opt/cbcfacil/data
|
|
||||||
|
|
||||||
# Limpiar backups antiguos (mantener ultimos 7 dias)
|
|
||||||
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recuperacion
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Detener servicio
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Restaurar datos
|
|
||||||
tar -xzf backup_*.tar.gz -C /opt/cbcfacil/
|
|
||||||
|
|
||||||
# Verificar permisos
|
|
||||||
chown -R cbcfacil:cbcfacil /opt/cbcfacil
|
|
||||||
|
|
||||||
# Reiniciar servicio
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting de Produccion
|
|
||||||
|
|
||||||
### Contenedor no Inicia
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar logs
|
|
||||||
docker logs cbcfacil
|
|
||||||
|
|
||||||
# Verificar configuracion
|
|
||||||
docker exec -it cbcfacil python -c "from config import settings; print(settings.has_webdav_config)"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error de Memoria GPU
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar GPUs disponibles
|
|
||||||
nvidia-smi
|
|
||||||
|
|
||||||
# Liberar memoria
|
|
||||||
sudo nvidia-smi --gpu-reset
|
|
||||||
|
|
||||||
# O limitar GPU
|
|
||||||
CUDA_VISIBLE_DEVICES=0
|
|
||||||
```
|
|
||||||
|
|
||||||
### WebDAV Connection Failed
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar conectividad
|
|
||||||
curl -u $NEXTCLOUD_USER:$NEXTCLOUD_PASSWORD $NEXTCLOUD_URL
|
|
||||||
|
|
||||||
# Verificar credenciales
|
|
||||||
echo "URL: $NEXTCLOUD_URL"
|
|
||||||
echo "User: $NEXTCLOUD_USER"
|
|
||||||
```
|
|
||||||
|
|
||||||
### High CPU Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Reducir threads
|
|
||||||
OMP_NUM_THREADS=2
|
|
||||||
MKL_NUM_THREADS=2
|
|
||||||
|
|
||||||
# Aumentar intervalo de polling
|
|
||||||
POLL_INTERVAL=15
|
|
||||||
```
|
|
||||||
|
|
||||||
## Checklist de Produccion
|
|
||||||
|
|
||||||
- [ ] Variables de entorno configuradas
|
|
||||||
- [ ] Credenciales seguras (.env.production)
|
|
||||||
- [ ] Usuario dedicado creado
|
|
||||||
- [ ] Permisos correctos asignados
|
|
||||||
- [ ] Logs configurados
|
|
||||||
- [ ] Health check funcionando
|
|
||||||
- [ ] Backup automatizado configurado
|
|
||||||
- [ ] Monitoreo activo
|
|
||||||
- [ ] SSL/TLS configurado (si aplica)
|
|
||||||
- [ ] Firewall configurado
|
|
||||||
|
|
||||||
## Recursos Adicionales
|
|
||||||
|
|
||||||
- `docs/SETUP.md` - Guia de configuracion inicial
|
|
||||||
- `docs/TESTING.md` - Guia de testing
|
|
||||||
- `ARCHITECTURE.md` - Documentacion arquitectonica
|
|
||||||
337
docs/SETUP.md
337
docs/SETUP.md
@@ -1,337 +0,0 @@
|
|||||||
# Guia de Configuracion - CBCFacil
|
|
||||||
|
|
||||||
Esta guia describe los pasos para configurar el entorno de desarrollo de CBCFacil.
|
|
||||||
|
|
||||||
## Requisitos Previos
|
|
||||||
|
|
||||||
### Software Requerido
|
|
||||||
|
|
||||||
| Componente | Version Minima | Recomendada |
|
|
||||||
|------------|----------------|-------------|
|
|
||||||
| Python | 3.10 | 3.11+ |
|
|
||||||
| Git | 2.0 | Latest |
|
|
||||||
| NVIDIA Driver | 535+ | 550+ (para CUDA 12.1) |
|
|
||||||
| Docker | 24.0 | 25.0+ (opcional) |
|
|
||||||
| Docker Compose | 2.20 | Latest (opcional) |
|
|
||||||
|
|
||||||
### Hardware Recomendado
|
|
||||||
|
|
||||||
- **CPU**: 4+ nucleos
|
|
||||||
- **RAM**: 8GB minimum (16GB+ recomendado)
|
|
||||||
- **GPU**: NVIDIA con 4GB+ VRAM (opcional, soporta CPU)
|
|
||||||
- **Almacenamiento**: 10GB+ libres
|
|
||||||
|
|
||||||
## Instalacion Paso a Paso
|
|
||||||
|
|
||||||
### 1. Clonar el Repositorio
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repo_url>
|
|
||||||
cd cbcfacil
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Crear Entorno Virtual
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Crear venv
|
|
||||||
python3 -m venv .venv
|
|
||||||
|
|
||||||
# Activar (Linux/macOS)
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Activar (Windows)
|
|
||||||
.venv\Scripts\activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Instalar Dependencias
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Actualizar pip
|
|
||||||
pip install --upgrade pip
|
|
||||||
|
|
||||||
# Instalar dependencias de produccion
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Instalar dependencias de desarrollo (opcional)
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Configurar Variables de Entorno
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Copiar template de configuracion
|
|
||||||
cp .env.example .env.secrets
|
|
||||||
|
|
||||||
# Editar con tus credenciales
|
|
||||||
nano .env.secrets
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Estructura de Archivos Necesaria
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Crear directorios requeridos
|
|
||||||
mkdir -p downloads resumenes_docx logs
|
|
||||||
|
|
||||||
# Verificar estructura
|
|
||||||
ls -la
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuracion de Credenciales
|
|
||||||
|
|
||||||
### Nextcloud/WebDAV
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Obligatorio para sincronizacion de archivos
|
|
||||||
NEXTCLOUD_URL=https://tu-nextcloud.com/remote.php/webdav
|
|
||||||
NEXTCLOUD_USER=tu_usuario
|
|
||||||
NEXTCLOUD_PASSWORD=tu_contrasena
|
|
||||||
```
|
|
||||||
|
|
||||||
### AI Providers (Opcional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Claude via Z.ai
|
|
||||||
ANTHROPIC_AUTH_TOKEN=sk-ant-...
|
|
||||||
|
|
||||||
# Google Gemini
|
|
||||||
GEMINI_API_KEY=AIza...
|
|
||||||
|
|
||||||
# Gemini CLI (opcional)
|
|
||||||
GEMINI_CLI_PATH=/usr/local/bin/gemini
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telegram (Opcional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TELEGRAM_TOKEN=bot_token
|
|
||||||
TELEGRAM_CHAT_ID=chat_id
|
|
||||||
```
|
|
||||||
|
|
||||||
### GPU Configuration (Opcional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Usar GPU especifica
|
|
||||||
CUDA_VISIBLE_DEVICES=0
|
|
||||||
|
|
||||||
# Usar todas las GPUs
|
|
||||||
CUDA_VISIBLE_DEVICES=all
|
|
||||||
|
|
||||||
# Forzar CPU
|
|
||||||
CUDA_VISIBLE_DEVICES=
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verificacion de Instalacion
|
|
||||||
|
|
||||||
### 1. Verificar Python
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python --version
|
|
||||||
# Debe mostrar Python 3.10+
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Verificar Dependencias
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -c "import torch; print(f'PyTorch: {torch.__version__}')"
|
|
||||||
python -c "import flask; print(f'Flask: {flask.__version__}')"
|
|
||||||
python -c "import whisper; print('Whisper instalado correctamente')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Verificar Configuracion
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -c "from config import settings; print(f'WebDAV configurado: {settings.has_webdav_config}')"
|
|
||||||
python -c "from config import settings; print(f'AI configurado: {settings.has_ai_config}')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Test Rapido
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar tests basicos
|
|
||||||
pytest tests/test_config.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuracion de Desarrollo
|
|
||||||
|
|
||||||
### IDE Recomendado
|
|
||||||
|
|
||||||
#### VS Code
|
|
||||||
|
|
||||||
```json
|
|
||||||
// .vscode/settings.json
|
|
||||||
{
|
|
||||||
"python.defaultInterpreterPath": ".venv/bin/python",
|
|
||||||
"python.linting.enabled": true,
|
|
||||||
"python.linting.pylintEnabled": true,
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"python.formatting.provider": "black"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PyCharm
|
|
||||||
|
|
||||||
1. Open Settings > Project > Python Interpreter
|
|
||||||
2. Add Interpreter > Existing Environment
|
|
||||||
3. Select `.venv/bin/python`
|
|
||||||
|
|
||||||
### Git Hooks (Opcional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Instalar pre-commit
|
|
||||||
pip install pre-commit
|
|
||||||
pre-commit install
|
|
||||||
|
|
||||||
# Verificar hooks
|
|
||||||
pre-commit run --all-files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Formateo de Codigo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Instalar formateadores
|
|
||||||
pip install black isort
|
|
||||||
|
|
||||||
# Formatear codigo
|
|
||||||
black .
|
|
||||||
isort .
|
|
||||||
|
|
||||||
# Verificar estilo
|
|
||||||
black --check .
|
|
||||||
isort --check-only .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ejecucion del Servicio
|
|
||||||
|
|
||||||
### Modo Desarrollo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Activar entorno virtual
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Ejecutar servicio completo
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
# Con logging verbose
|
|
||||||
LOG_LEVEL=DEBUG python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Comandos CLI Disponibles
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Transcribir audio
|
|
||||||
python main.py whisper <archivo_audio> <directorio_output>
|
|
||||||
|
|
||||||
# Procesar PDF
|
|
||||||
python main.py pdf <archivo_pdf> <directorio_output>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dashboard
|
|
||||||
|
|
||||||
El dashboard estara disponible en:
|
|
||||||
- URL: http://localhost:5000
|
|
||||||
- API: http://localhost:5000/api/
|
|
||||||
|
|
||||||
## Solucion de Problemas Comunes
|
|
||||||
|
|
||||||
### Error: "Module not found"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar que el venv esta activado
|
|
||||||
which python
|
|
||||||
# Debe mostrar path hacia .venv/bin/python
|
|
||||||
|
|
||||||
# Reinstalar dependencias
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error: "CUDA out of memory"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Reducir uso de GPU
|
|
||||||
export CUDA_VISIBLE_DEVICES=
|
|
||||||
|
|
||||||
# O usar solo una GPU
|
|
||||||
export CUDA_VISIBLE_DEVICES=0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error: "WebDAV connection failed"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar credenciales
|
|
||||||
echo $NEXTCLOUD_URL
|
|
||||||
echo $NEXTCLOUD_USER
|
|
||||||
|
|
||||||
# Probar conexion manualmente
|
|
||||||
curl -u $NEXTCLOUD_USER:$NEXTCLOUD_PASSWORD $NEXTCLOUD_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error: "Telegram token invalid"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar token con BotFather
|
|
||||||
# https://t.me/BotFather
|
|
||||||
|
|
||||||
# Verificar variables de entorno
|
|
||||||
echo $TELEGRAM_TOKEN
|
|
||||||
echo $TELEGRAM_CHAT_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error: "Whisper model not found"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# El modelo se descarga automaticamente la primera vez
|
|
||||||
# Para forzar recarga:
|
|
||||||
rm -rf ~/.cache/whisper
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuracion de GPU (Opcional)
|
|
||||||
|
|
||||||
### Verificar Instalacion de CUDA
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar drivers NVIDIA
|
|
||||||
nvidia-smi
|
|
||||||
|
|
||||||
# Verificar CUDA Toolkit
|
|
||||||
nvcc --version
|
|
||||||
|
|
||||||
# Verificar PyTorch CUDA
|
|
||||||
python -c "import torch; print(f'CUDA available: {torch.cuda.is_available()}')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configurar Memoria GPU
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# En .env.secrets
|
|
||||||
PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512
|
|
||||||
```
|
|
||||||
|
|
||||||
## Variables de Entorno Completa
|
|
||||||
|
|
||||||
| Variable | Requerido | Default | Descripcion |
|
|
||||||
|----------|-----------|---------|-------------|
|
|
||||||
| NEXTCLOUD_URL | Si | - | URL WebDAV de Nextcloud |
|
|
||||||
| NEXTCLOUD_USER | Si | - | Usuario Nextcloud |
|
|
||||||
| NEXTCLOUD_PASSWORD | Si | - | Contrasena Nextcloud |
|
|
||||||
| ANTHROPIC_AUTH_TOKEN | No | - | Token Claude/Z.ai |
|
|
||||||
| GEMINI_API_KEY | No | - | API Key Gemini |
|
|
||||||
| TELEGRAM_TOKEN | No | - | Token Bot Telegram |
|
|
||||||
| TELEGRAM_CHAT_ID | No | - | Chat ID Telegram |
|
|
||||||
| CUDA_VISIBLE_DEVICES | No | "all" | GPUs a usar |
|
|
||||||
| POLL_INTERVAL | No | 5 | Segundos entre polls |
|
|
||||||
| LOG_LEVEL | No | "INFO" | Nivel de logging |
|
|
||||||
| DEBUG | No | false | Modo debug |
|
|
||||||
| DASHBOARD_PORT | No | 5000 | Puerto del dashboard |
|
|
||||||
| DASHBOARD_HOST | No | "0.0.0.0" | Host del dashboard |
|
|
||||||
|
|
||||||
## Siguientes Pasos
|
|
||||||
|
|
||||||
1. Verificar instalacion con tests
|
|
||||||
2. Configurar integracion con Nextcloud
|
|
||||||
3. Probar procesamiento de archivos
|
|
||||||
4. Configurar notificaciones Telegram (opcional)
|
|
||||||
|
|
||||||
Ver juga:
|
|
||||||
- `docs/TESTING.md` - Guia de testing
|
|
||||||
- `docs/DEPLOYMENT.md` - Guia de despliegue
|
|
||||||
- `ARCHITECTURE.md` - Documentacion arquitectonica
|
|
||||||
482
docs/TESTING.md
482
docs/TESTING.md
@@ -1,482 +0,0 @@
|
|||||||
# Guia de Testing - CBCFacil
|
|
||||||
|
|
||||||
Esta guia describe como ejecutar y escribir tests para CBCFacil.
|
|
||||||
|
|
||||||
## Estructura de Tests
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── conftest.py # Fixtures compartidos
|
|
||||||
├── test_config.py # Tests de configuracion
|
|
||||||
├── test_storage.py # Tests de almacenamiento
|
|
||||||
├── test_webdav.py # Tests de WebDAV
|
|
||||||
├── test_processors.py # Tests de procesadores
|
|
||||||
├── test_ai_providers.py # Tests de AI providers
|
|
||||||
├── test_vram_manager.py # Tests de VRAM manager
|
|
||||||
└── test_main_integration.py # Tests de integracion
|
|
||||||
```
|
|
||||||
|
|
||||||
## Instalacion de Dependencias de Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Activar entorno virtual
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Instalar dependencias de desarrollo
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
# Verificar instalacion
|
|
||||||
pytest --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ejecutar Tests
|
|
||||||
|
|
||||||
### Todos los Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar todos los tests
|
|
||||||
pytest tests/
|
|
||||||
|
|
||||||
# Con output detallado
|
|
||||||
pytest tests/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests Especificos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Tests de configuracion
|
|
||||||
pytest tests/test_config.py -v
|
|
||||||
|
|
||||||
# Tests de almacenamiento
|
|
||||||
pytest tests/test_storage.py -v
|
|
||||||
|
|
||||||
# Tests de WebDAV
|
|
||||||
pytest tests/test_webdav.py -v
|
|
||||||
|
|
||||||
# Tests de procesadores
|
|
||||||
pytest tests/test_processors.py -v
|
|
||||||
|
|
||||||
# Tests de AI providers
|
|
||||||
pytest tests/test_ai_providers.py -v
|
|
||||||
|
|
||||||
# Tests de VRAM manager
|
|
||||||
pytest tests/test_vram_manager.py -v
|
|
||||||
|
|
||||||
# Tests de integracion
|
|
||||||
pytest tests/test_main_integration.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests con Coverage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Coverage basico
|
|
||||||
pytest tests/ --cov=cbcfacil
|
|
||||||
|
|
||||||
# Coverage con reporte HTML
|
|
||||||
pytest tests/ --cov=cbcfacil --cov-report=html
|
|
||||||
|
|
||||||
# Coverage con reporte term-missing
|
|
||||||
pytest tests/ --cov=cbcfacil --cov-report=term-missing
|
|
||||||
|
|
||||||
# Coverage por modulo
|
|
||||||
pytest tests/ --cov=cbcfacil --cov-report=term-missing --cov-report=annotate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests en Modo Watch
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Recargar automaticamente al detectar cambios
|
|
||||||
pytest-watch tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests Parallelos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar tests en paralelo
|
|
||||||
pytest tests/ -n auto
|
|
||||||
|
|
||||||
# Con numero fijo de workers
|
|
||||||
pytest tests/ -n 4
|
|
||||||
```
|
|
||||||
|
|
||||||
## Escribir Nuevos Tests
|
|
||||||
|
|
||||||
### Estructura Basica
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_ejemplo.py
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
class TestEjemplo:
|
|
||||||
"""Clase de tests para un modulo"""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Setup antes de cada test"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def teardown_method(self):
|
|
||||||
"""Cleanup despues de cada test"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_funcion_basica(self):
|
|
||||||
"""Test de una funcion basica"""
|
|
||||||
# Arrange
|
|
||||||
input_value = "test"
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = mi_funcion(input_value)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is not None
|
|
||||||
assert result == "expected"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usar Fixtures
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/conftest.py
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_directory(tmp_path):
|
|
||||||
"""Fixture para directorio temporal"""
|
|
||||||
dir_path = tmp_path / "test_files"
|
|
||||||
dir_path.mkdir()
|
|
||||||
return dir_path
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_settings():
|
|
||||||
"""Fixture con settings de prueba"""
|
|
||||||
class MockSettings:
|
|
||||||
NEXTCLOUD_URL = "https://test.example.com"
|
|
||||||
NEXTCLOUD_USER = "test_user"
|
|
||||||
NEXTCLOUD_PASSWORD = "test_pass"
|
|
||||||
return MockSettings()
|
|
||||||
|
|
||||||
# En tu test
|
|
||||||
def test_con_fixture(temp_directory, mock_settings):
|
|
||||||
"""Test usando fixtures"""
|
|
||||||
assert temp_directory.exists()
|
|
||||||
assert mock_settings.NEXTCLOUD_URL == "https://test.example.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de Configuracion
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_config.py
|
|
||||||
import pytest
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
class TestSettings:
|
|
||||||
"""Tests para configuracion"""
|
|
||||||
|
|
||||||
def test_has_webdav_config_true(self):
|
|
||||||
"""Test con WebDAV configurado"""
|
|
||||||
# Verificar que las properties funcionan
|
|
||||||
assert hasattr(settings, 'has_webdav_config')
|
|
||||||
assert hasattr(settings, 'has_ai_config')
|
|
||||||
|
|
||||||
def test_processed_files_path(self):
|
|
||||||
"""Test del path de archivos procesados"""
|
|
||||||
path = settings.processed_files_path
|
|
||||||
assert isinstance(path, Path)
|
|
||||||
assert path.suffix == ".txt"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de WebDAV
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_webdav.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
class TestWebDAVService:
|
|
||||||
"""Tests para WebDAV Service"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def webdav_service(self):
|
|
||||||
"""Crear instancia del servicio"""
|
|
||||||
from services.webdav_service import webdav_service
|
|
||||||
return webdav_service
|
|
||||||
|
|
||||||
def test_list_remote_path(self, webdav_service):
|
|
||||||
"""Test de listado de archivos remotos"""
|
|
||||||
# Mock del cliente WebDAV
|
|
||||||
with patch('services.webdav_service.WebDAVClient') as mock_client:
|
|
||||||
mock_instance = Mock()
|
|
||||||
mock_instance.list.return_value = ['file1.pdf', 'file2.mp3']
|
|
||||||
mock_client.return_value = mock_instance
|
|
||||||
|
|
||||||
# Inicializar servicio
|
|
||||||
webdav_service.initialize()
|
|
||||||
|
|
||||||
# Test
|
|
||||||
files = webdav_service.list("TestFolder")
|
|
||||||
assert len(files) == 2
|
|
||||||
assert "file1.pdf" in files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de Procesadores
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_processors.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
class TestAudioProcessor:
|
|
||||||
"""Tests para Audio Processor"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def processor(self):
|
|
||||||
"""Crear procesador"""
|
|
||||||
from processors.audio_processor import AudioProcessor
|
|
||||||
return AudioProcessor()
|
|
||||||
|
|
||||||
def test_process_audio_file(self, processor, tmp_path):
|
|
||||||
"""Test de procesamiento de audio"""
|
|
||||||
# Crear archivo de prueba
|
|
||||||
audio_file = tmp_path / "test.mp3"
|
|
||||||
audio_file.write_bytes(b"fake audio content")
|
|
||||||
|
|
||||||
# Mock de Whisper
|
|
||||||
with patch('processors.audio_processor.whisper') as mock_whisper:
|
|
||||||
mock_whisper.load_model.return_value.transcribe.return_value = {
|
|
||||||
"text": "Texto transcrito de prueba"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar
|
|
||||||
result = processor.process(str(audio_file))
|
|
||||||
|
|
||||||
# Verificar
|
|
||||||
assert result is not None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de AI Providers
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_ai_providers.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
class TestClaudeProvider:
|
|
||||||
"""Tests para Claude Provider"""
|
|
||||||
|
|
||||||
def test_summarize_text(self):
|
|
||||||
"""Test de resumen con Claude"""
|
|
||||||
from services.ai.claude_provider import ClaudeProvider
|
|
||||||
|
|
||||||
provider = ClaudeProvider()
|
|
||||||
test_text = "Texto largo para resumir..."
|
|
||||||
|
|
||||||
# Mock de la llamada API
|
|
||||||
with patch.object(provider, '_call_api') as mock_call:
|
|
||||||
mock_call.return_value = "Texto resumido"
|
|
||||||
|
|
||||||
result = provider.summarize(test_text)
|
|
||||||
|
|
||||||
assert result == "Texto resumido"
|
|
||||||
mock_call.assert_called_once()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de Integracion
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_main_integration.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
class TestMainIntegration:
|
|
||||||
"""Tests de integracion del main"""
|
|
||||||
|
|
||||||
def test_main_loop_no_files(self):
|
|
||||||
"""Test del loop principal sin archivos nuevos"""
|
|
||||||
with patch('main.webdav_service') as mock_webdav:
|
|
||||||
with patch('main.processed_registry') as mock_registry:
|
|
||||||
mock_webdav.list.return_value = []
|
|
||||||
mock_registry.is_processed.return_value = True
|
|
||||||
|
|
||||||
# El loop no debe procesar nada
|
|
||||||
# Verificar que no se llama a procesadores
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuracion de pytest
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# pytest.ini o pyproject.toml
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
python_files = ["test_*.py"]
|
|
||||||
python_classes = ["Test*"]
|
|
||||||
python_functions = ["test_*"]
|
|
||||||
addopts = [
|
|
||||||
"-v",
|
|
||||||
"--tb=short",
|
|
||||||
"--strict-markers",
|
|
||||||
]
|
|
||||||
filterwarnings = [
|
|
||||||
"ignore::DeprecationWarning",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mejores Practicas
|
|
||||||
|
|
||||||
### 1. Nombrado de Tests
|
|
||||||
|
|
||||||
```python
|
|
||||||
# BIEN
|
|
||||||
def test_webdav_service_list_returns_files():
|
|
||||||
...
|
|
||||||
|
|
||||||
def test_processed_registry_is_processed_true_for_processed_file():
|
|
||||||
...
|
|
||||||
|
|
||||||
# MAL
|
|
||||||
def test_list():
|
|
||||||
...
|
|
||||||
|
|
||||||
def test_check():
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Estructura AAA
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_ejemplo_aaa():
|
|
||||||
# Arrange
|
|
||||||
input_data = {"key": "value"}
|
|
||||||
expected = "result"
|
|
||||||
|
|
||||||
# Act
|
|
||||||
actual = function_under_test(input_data)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert actual == expected
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Tests Aislados
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Cada test debe ser independiente
|
|
||||||
def test_independent():
|
|
||||||
# No depender de estado de otros tests
|
|
||||||
# Usar fixtures para setup/cleanup
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Evitar TestLego
|
|
||||||
|
|
||||||
```python
|
|
||||||
# BIEN - Test del comportamiento, no la implementacion
|
|
||||||
def test_registry_returns_true_for_processed_file():
|
|
||||||
registry = ProcessedRegistry()
|
|
||||||
registry.save("file.txt")
|
|
||||||
assert registry.is_processed("file.txt") is True
|
|
||||||
|
|
||||||
# MAL - Test de implementacion
|
|
||||||
def test_registry_uses_set_internally():
|
|
||||||
# No testar detalles de implementacion
|
|
||||||
registry = ProcessedRegistry()
|
|
||||||
assert hasattr(registry, '_processed_files')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Mocks Appropriados
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Usar mocks para dependencias externas
|
|
||||||
from unittest.mock import Mock, patch, MagicMock
|
|
||||||
|
|
||||||
def test_with_mocked_api():
|
|
||||||
with patch('requests.get') as mock_get:
|
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.json.return_value = {"key": "value"}
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
|
|
||||||
result = my_api_function()
|
|
||||||
|
|
||||||
assert result == {"key": "value"}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coverage Objetivo
|
|
||||||
|
|
||||||
| Componente | Coverage Minimo |
|
|
||||||
|------------|-----------------|
|
|
||||||
| config/ | 90% |
|
|
||||||
| core/ | 90% |
|
|
||||||
| services/ | 70% |
|
|
||||||
| processors/ | 60% |
|
|
||||||
| storage/ | 90% |
|
|
||||||
| api/ | 80% |
|
|
||||||
|
|
||||||
## Integracion con CI/CD
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/tests.yml
|
|
||||||
name: Tests
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
pytest tests/ --cov=cbcfacil --cov-report=xml
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Tests Fallan por Imports
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar que el venv esta activado
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Reinstalar el paquete en modo desarrollo
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests Muy Lentos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar en paralelo
|
|
||||||
pytest tests/ -n auto
|
|
||||||
|
|
||||||
# O ejecutar solo tests rapidos
|
|
||||||
pytest tests/ -m "not slow"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Memory Errors
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Reducir workers
|
|
||||||
pytest tests/ -n 2
|
|
||||||
|
|
||||||
# O ejecutar secuencial
|
|
||||||
pytest tests/ -n 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recursos Adicionales
|
|
||||||
|
|
||||||
- [Documentacion de pytest](https://docs.pytest.org/)
|
|
||||||
- [Documentacion de unittest.mock](https://docs.python.org/3/library/unittest.mock.html)
|
|
||||||
- [pytest-cov](https://pytest-cov.readthedocs.io/)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""
|
|
||||||
Document generation package for CBCFacil
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .generators import DocumentGenerator
|
|
||||||
|
|
||||||
__all__ = ['DocumentGenerator']
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"""
|
|
||||||
Document generation utilities
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, List, Tuple
|
|
||||||
from core import FileProcessingError
|
|
||||||
from config import settings
|
|
||||||
from services.ai import ai_provider_factory
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentGenerator:
|
|
||||||
"""Generate documents from processed text"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self.ai_provider = ai_provider_factory.get_best_provider()
|
|
||||||
|
|
||||||
def generate_summary(self, text: str, base_name: str) -> Tuple[bool, str, Dict[str, Any]]:
|
|
||||||
"""Generate unified summary"""
|
|
||||||
self.logger.info(f"Generating summary for {base_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Step 1: Generate Bullet Points (Chunking handled by provider or single prompt for now)
|
|
||||||
# Note: We use the main provider (Claude/Zai) for content generation
|
|
||||||
self.logger.info("Generating bullet points...")
|
|
||||||
bullet_prompt = f"""Analiza el siguiente texto y extrae entre 5 y 8 bullet points clave en español.
|
|
||||||
|
|
||||||
REGLAS ESTRICTAS:
|
|
||||||
1. Devuelve ÚNICAMENTE bullet points, cada línea iniciando con "- "
|
|
||||||
2. Cada bullet debe ser conciso (12-20 palabras) y resaltar datos, fechas, conceptos o conclusiones importantes
|
|
||||||
3. NO agregues introducciones, conclusiones ni texto explicativo
|
|
||||||
4. Concéntrate en los puntos más importantes del texto
|
|
||||||
5. Incluye fechas, datos específicos y nombres relevantes si los hay
|
|
||||||
|
|
||||||
Texto:
|
|
||||||
{text[:15000]}""" # Truncate to avoid context limits if necessary, though providers handle it differently
|
|
||||||
|
|
||||||
try:
|
|
||||||
bullet_points = self.ai_provider.generate_text(bullet_prompt)
|
|
||||||
self.logger.info(f"Bullet points generated: {len(bullet_points)}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Bullet point generation failed: {e}")
|
|
||||||
bullet_points = "- Puntos clave no disponibles por error en IA"
|
|
||||||
|
|
||||||
# Step 2: Generate Unified Summary
|
|
||||||
self.logger.info("Generating unified summary...")
|
|
||||||
summary_prompt = f"""Eres un profesor universitario experto en historia del siglo XX. Redacta un resumen académico integrado en español usando el texto y los bullet points extraídos.
|
|
||||||
|
|
||||||
REQUISITOS ESTRICTOS:
|
|
||||||
- Extensión entre 500-700 palabras
|
|
||||||
- Usa encabezados Markdown con jerarquía clara (##, ###)
|
|
||||||
- Desarrolla los puntos clave con profundidad y contexto histórico
|
|
||||||
- Mantén un tono académico y analítico
|
|
||||||
- Incluye conclusiones significativas
|
|
||||||
- NO agregues texto fuera del resumen
|
|
||||||
- Devuelve únicamente el resumen en formato Markdown
|
|
||||||
|
|
||||||
Contenido a resumir:
|
|
||||||
{text[:20000]}
|
|
||||||
|
|
||||||
Puntos clave a incluir obligatoriamente:
|
|
||||||
{bullet_points}"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_summary = self.ai_provider.generate_text(summary_prompt)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Raw summary generation failed: {e}")
|
|
||||||
raise e
|
|
||||||
|
|
||||||
# Step 3: Format with Gemini (using GeminiProvider explicitly)
|
|
||||||
self.logger.info("Formatting summary with Gemini...")
|
|
||||||
format_prompt = f"""Revisa y mejora el siguiente resumen en Markdown para que sea perfectamente legible:
|
|
||||||
|
|
||||||
{raw_summary}
|
|
||||||
|
|
||||||
Instrucciones:
|
|
||||||
- Corrige cualquier error de formato
|
|
||||||
- Asegúrate de que los encabezados estén bien espaciados
|
|
||||||
- Verifica que las viñetas usen "- " correctamente
|
|
||||||
- Mantén exactamente el contenido existente
|
|
||||||
- Devuelve únicamente el resumen formateado sin texto adicional"""
|
|
||||||
|
|
||||||
# Use generic Gemini provider for formatting as requested
|
|
||||||
from services.ai.gemini_provider import GeminiProvider
|
|
||||||
formatter = GeminiProvider()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if formatter.is_available():
|
|
||||||
summary = formatter.generate_text(format_prompt)
|
|
||||||
else:
|
|
||||||
self.logger.warning("Gemini formatter not available, using raw summary")
|
|
||||||
summary = raw_summary
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Formatting failed ({e}), using raw summary")
|
|
||||||
summary = raw_summary
|
|
||||||
|
|
||||||
# Generate filename
|
|
||||||
filename = self._generate_filename(text, summary)
|
|
||||||
|
|
||||||
# Create document
|
|
||||||
markdown_path = self._create_markdown(summary, base_name)
|
|
||||||
docx_path = self._create_docx(summary, base_name)
|
|
||||||
pdf_path = self._create_pdf(summary, base_name)
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
'markdown_path': str(markdown_path),
|
|
||||||
'docx_path': str(docx_path),
|
|
||||||
'pdf_path': str(pdf_path),
|
|
||||||
'docx_name': Path(docx_path).name,
|
|
||||||
'summary': summary,
|
|
||||||
'filename': filename
|
|
||||||
}
|
|
||||||
|
|
||||||
return True, summary, metadata
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Document generation process failed: {e}")
|
|
||||||
return False, "", {}
|
|
||||||
|
|
||||||
def _generate_filename(self, text: str, summary: str) -> str:
|
|
||||||
"""Generate intelligent filename"""
|
|
||||||
try:
|
|
||||||
# Use AI to extract key topics
|
|
||||||
prompt = f"""Extract 2-3 key topics from this summary to create a filename.
|
|
||||||
Summary: {summary}
|
|
||||||
|
|
||||||
Return only the topics separated by hyphens, max 20 chars each, in Spanish:"""
|
|
||||||
|
|
||||||
topics_text = self.ai_provider.sanitize_input(prompt) if hasattr(self.ai_provider, 'sanitize_input') else summary[:100]
|
|
||||||
|
|
||||||
# Simple topic extraction
|
|
||||||
topics = re.findall(r'\b[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+\b', topics_text)[:3]
|
|
||||||
if not topics:
|
|
||||||
topics = ['documento']
|
|
||||||
|
|
||||||
# Limit topic length
|
|
||||||
topics = [t[:settings.MAX_FILENAME_TOPICS_LENGTH] for t in topics]
|
|
||||||
|
|
||||||
filename = '_'.join(topics)[:settings.MAX_FILENAME_LENGTH]
|
|
||||||
return filename
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Filename generation failed: {e}")
|
|
||||||
return base_name[:settings.MAX_FILENAME_BASE_LENGTH]
|
|
||||||
|
|
||||||
def _create_markdown(self, summary: str, base_name: str) -> Path:
|
|
||||||
"""Create Markdown document"""
|
|
||||||
output_dir = settings.LOCAL_DOWNLOADS_PATH
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
output_path = output_dir / f"{base_name}_unificado.md"
|
|
||||||
|
|
||||||
content = f"""# {base_name.replace('_', ' ').title()}
|
|
||||||
|
|
||||||
## Resumen
|
|
||||||
|
|
||||||
{summary}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Generado por CBCFacil*
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
def _create_docx(self, summary: str, base_name: str) -> Path:
|
|
||||||
"""Create DOCX document"""
|
|
||||||
try:
|
|
||||||
from docx import Document
|
|
||||||
from docx.shared import Inches
|
|
||||||
except ImportError:
|
|
||||||
raise FileProcessingError("python-docx not installed")
|
|
||||||
|
|
||||||
output_dir = settings.LOCAL_DOCX
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
output_path = output_dir / f"{base_name}_unificado.docx"
|
|
||||||
|
|
||||||
doc = Document()
|
|
||||||
doc.add_heading(base_name.replace('_', ' ').title(), 0)
|
|
||||||
|
|
||||||
doc.add_heading('Resumen', level=1)
|
|
||||||
doc.add_paragraph(summary)
|
|
||||||
|
|
||||||
doc.add_page_break()
|
|
||||||
doc.add_paragraph(f"*Generado por CBCFacil*")
|
|
||||||
|
|
||||||
doc.save(output_path)
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
def _create_pdf(self, summary: str, base_name: str) -> Path:
|
|
||||||
"""Create PDF document"""
|
|
||||||
try:
|
|
||||||
from reportlab.lib.pagesizes import letter
|
|
||||||
from reportlab.pdfgen import canvas
|
|
||||||
except ImportError:
|
|
||||||
raise FileProcessingError("reportlab not installed")
|
|
||||||
|
|
||||||
output_dir = settings.LOCAL_DOWNLOADS_PATH
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
output_path = output_dir / f"{base_name}_unificado.pdf"
|
|
||||||
|
|
||||||
c = canvas.Canvas(str(output_path), pagesize=letter)
|
|
||||||
width, height = letter
|
|
||||||
|
|
||||||
# Add title
|
|
||||||
c.setFont("Helvetica-Bold", 16)
|
|
||||||
title = base_name.replace('_', ' ').title()
|
|
||||||
c.drawString(100, height - 100, title)
|
|
||||||
|
|
||||||
# Add summary
|
|
||||||
c.setFont("Helvetica", 12)
|
|
||||||
y_position = height - 140
|
|
||||||
|
|
||||||
# Simple text wrapping
|
|
||||||
lines = summary.split('\n')
|
|
||||||
for line in lines:
|
|
||||||
if y_position < 100:
|
|
||||||
c.showPage()
|
|
||||||
y_position = height - 100
|
|
||||||
c.setFont("Helvetica", 12)
|
|
||||||
|
|
||||||
c.drawString(100, y_position, line)
|
|
||||||
y_position -= 20
|
|
||||||
|
|
||||||
c.showPage()
|
|
||||||
c.save()
|
|
||||||
|
|
||||||
return output_path
|
|
||||||
866
main.py
866
main.py
@@ -1,460 +1,498 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
CBCFacil - Main Service Entry Point
|
CBFacil - Sistema de transcripción de audio con IA y Notion
|
||||||
Unified AI service for document processing (audio, PDF, text)
|
|
||||||
|
Características:
|
||||||
|
- Polling de Nextcloud vía WebDAV
|
||||||
|
- Transcripción con Whisper (medium, GPU)
|
||||||
|
- Resúmenes con IA (GLM-4.7)
|
||||||
|
- Generación de PDF
|
||||||
|
- Notificaciones Telegram
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import fcntl
|
|
||||||
import os
|
import os
|
||||||
import json
|
import sys
|
||||||
from pathlib import Path
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
# Configure logging with JSON formatter for production
|
import torch
|
||||||
class JSONFormatter(logging.Formatter):
|
import torch.cuda
|
||||||
"""JSON formatter for structured logging in production"""
|
|
||||||
|
from flask import Flask
|
||||||
def format(self, record):
|
from flask_cors import CORS
|
||||||
log_entry = {
|
|
||||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
# API y configuración
|
||||||
"level": record.levelname,
|
from api import api_bp, init_api
|
||||||
"message": record.getMessage(),
|
from config import settings
|
||||||
"module": record.module,
|
from processors.audio_processor import AudioProcessor, AudioProcessingError
|
||||||
"function": record.funcName,
|
from services import WebDAVService
|
||||||
"line": record.lineno
|
from services.webdav_service import WebDAVService as WebDAVService_Class
|
||||||
}
|
from services.telegram_service import TelegramService
|
||||||
|
from watchers import RemoteFolderWatcher
|
||||||
# Add exception info if present
|
|
||||||
if record.exc_info:
|
# Importar ProcessManager del core
|
||||||
log_entry["exception"] = self.formatException(record.exc_info)
|
from core.process_manager import ProcessManager as CoreProcessManager, ProcessInfo
|
||||||
|
|
||||||
return json.dumps(log_entry)
|
# Paquetes de logging
|
||||||
|
from pythonjsonlogger import jsonlogger
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(jsonlogger.JsonFormatter):
|
||||||
|
"""Formateador JSON para logs."""
|
||||||
|
|
||||||
|
def add_fields(self, log_record: dict, record: logging.LogRecord, message_dict: dict) -> None:
|
||||||
|
super().add_fields(log_record, record, message_dict)
|
||||||
|
log_record["timestamp"] = datetime.utcnow().isoformat() + "Z"
|
||||||
|
log_record["level"] = record.levelname
|
||||||
|
log_record["module"] = record.module
|
||||||
|
|
||||||
|
|
||||||
def setup_logging() -> logging.Logger:
|
def setup_logging() -> logging.Logger:
|
||||||
"""Setup logging configuration"""
|
"""Configura el sistema de logging."""
|
||||||
from config import settings
|
logger = logging.getLogger()
|
||||||
|
|
||||||
# Create logger
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper()))
|
logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper()))
|
||||||
|
|
||||||
# Remove existing handlers
|
# Limpiar handlers existentes
|
||||||
logger.handlers.clear()
|
logger.handlers.clear()
|
||||||
|
|
||||||
# Console handler
|
# Handler de consola
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
if settings.is_production:
|
if settings.is_production:
|
||||||
console_handler.setFormatter(JSONFormatter())
|
console_handler.setFormatter(JSONFormatter(
|
||||||
else:
|
"%(timestamp)s %(level)s %(name)s %(message)s"
|
||||||
console_handler.setFormatter(logging.Formatter(
|
|
||||||
"%(asctime)s [%(levelname)s] - %(name)s - %(message)s"
|
|
||||||
))
|
))
|
||||||
|
else:
|
||||||
|
console_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
|
)
|
||||||
logger.addHandler(console_handler)
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
# File handler if configured
|
# Handler de archivo si está configurado
|
||||||
if settings.LOG_FILE:
|
if settings.LOG_FILE:
|
||||||
file_handler = logging.FileHandler(settings.LOG_FILE)
|
file_handler = logging.FileHandler(settings.LOG_FILE)
|
||||||
file_handler.setFormatter(JSONFormatter())
|
file_handler.setFormatter(JSONFormatter(
|
||||||
|
"%(timestamp)s %(level)s %(name)s %(message)s"
|
||||||
|
))
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
def acquire_lock() -> int:
|
# ============================================================================
|
||||||
"""Acquire single instance lock"""
|
# MONITOR GLOBAL - Solo UN archivo procesando a la vez
|
||||||
lock_file = Path(os.getenv("LOCAL_STATE_DIR", str(Path(__file__).parent))) / ".main_service.lock"
|
# ============================================================================
|
||||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lock_fd = open(lock_file, 'w')
|
class ProcessingMonitor:
|
||||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
"""Monitor global para garantizar SOLO UN archivo en proceso a la vez."""
|
||||||
lock_fd.write(str(os.getpid()))
|
|
||||||
lock_fd.flush()
|
_instance = None
|
||||||
logger.info(f"Lock acquired. PID: {os.getpid()}")
|
_lock = threading.Lock()
|
||||||
return lock_fd
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._processing = False
|
||||||
|
cls._instance._current_file = None
|
||||||
|
cls._instance._queue = deque()
|
||||||
|
cls._instance._queued_files = set()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def is_processing(self) -> bool:
|
||||||
|
"""Verifica si hay un archivo en proceso."""
|
||||||
|
with self._lock:
|
||||||
|
return self._processing
|
||||||
|
|
||||||
|
def get_current_file(self) -> Optional[str]:
|
||||||
|
"""Obtiene el archivo actual en proceso."""
|
||||||
|
with self._lock:
|
||||||
|
return self._current_file
|
||||||
|
|
||||||
|
def add_to_queue(self, file_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Agrega un archivo a la cola.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se agregó a la cola, False si ya estaba.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
file_key = str(file_path)
|
||||||
|
if file_key in self._queued_files:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._queue.append(file_path)
|
||||||
|
self._queued_files.add(file_key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start_processing(self, file_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Marca que se está procesando un archivo.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se pudo iniciar el procesamiento, False si ya había uno en curso.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._processing:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._processing = True
|
||||||
|
self._current_file = str(file_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def finish_processing(self):
|
||||||
|
"""Marca que el procesamiento terminó y retorna el siguiente archivo si existe."""
|
||||||
|
with self._lock:
|
||||||
|
self._processing = False
|
||||||
|
file_key = self._current_file
|
||||||
|
self._current_file = None
|
||||||
|
|
||||||
|
# Remover de queued_files
|
||||||
|
if file_key:
|
||||||
|
self._queued_files.discard(file_key)
|
||||||
|
|
||||||
|
# Intentar procesar el siguiente archivo (fuera del lock)
|
||||||
|
self._process_next()
|
||||||
|
|
||||||
|
def get_queue_size(self) -> int:
|
||||||
|
"""Retorna el tamaño de la cola."""
|
||||||
|
with self._lock:
|
||||||
|
return len(self._queue)
|
||||||
|
|
||||||
|
def _process_next(self):
|
||||||
|
"""Procesa el siguiente archivo en la cola (debe llamarse fuera del lock)."""
|
||||||
|
with self._lock:
|
||||||
|
if self._processing or not self._queue:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_file = self._queue.popleft()
|
||||||
|
self._processing = True
|
||||||
|
self._current_file = str(next_file)
|
||||||
|
|
||||||
|
# Iniciar procesamiento fuera del lock
|
||||||
|
logger.info(
|
||||||
|
"🔄 Auto-starting NEXT file from queue",
|
||||||
|
extra={"file": next_file.name, "remaining": len(self._queue)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear un thread wrapper que garantiza cleanup
|
||||||
|
def process_wrapper():
|
||||||
|
try:
|
||||||
|
_process_file_async_safe(next_file)
|
||||||
|
finally:
|
||||||
|
# Siempre limpiar, incluso si hay excepción
|
||||||
|
monitor.finish_processing()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=process_wrapper, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
def release_lock(lock_fd) -> None:
|
# Instancia global del monitor
|
||||||
"""Release lock"""
|
monitor = ProcessingMonitor()
|
||||||
try:
|
|
||||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
|
|
||||||
lock_fd.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not release lock: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_configuration() -> None:
|
# ============================================================================
|
||||||
"""Validate configuration at startup"""
|
# Polling Service
|
||||||
from config.validators import validate_environment, ConfigurationError
|
# ============================================================================
|
||||||
|
|
||||||
try:
|
|
||||||
warnings = validate_environment()
|
|
||||||
if warnings:
|
|
||||||
logger.info(f"Configuration validation completed with {len(warnings)} warnings")
|
|
||||||
except ConfigurationError as e:
|
|
||||||
logger.error(f"Configuration validation failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
class PollingService:
|
||||||
|
"""Servicio principal de polling."""
|
||||||
|
|
||||||
def check_service_health() -> dict:
|
def __init__(self) -> None:
|
||||||
"""
|
self.webdav_service: Optional[WebDAVService_Class] = None
|
||||||
Check health of all external services
|
self.watcher: Optional[RemoteFolderWatcher] = None
|
||||||
Returns dict with health status
|
self.flask_app: Optional[Flask] = None
|
||||||
"""
|
self._telegram_service: Optional[TelegramService] = None
|
||||||
from config import settings
|
self._process_manager: Optional[CoreProcessManager] = None
|
||||||
from services.webdav_service import webdav_service
|
self._running = False
|
||||||
|
|
||||||
health_status = {
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"status": "healthy",
|
|
||||||
"services": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check WebDAV
|
|
||||||
try:
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
# Try a simple operation
|
|
||||||
webdav_service.list(".")
|
|
||||||
health_status["services"]["webdav"] = {"status": "healthy"}
|
|
||||||
else:
|
|
||||||
health_status["services"]["webdav"] = {"status": "not_configured"}
|
|
||||||
except Exception as e:
|
|
||||||
health_status["services"]["webdav"] = {
|
|
||||||
"status": "unhealthy",
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
health_status["status"] = "degraded"
|
|
||||||
|
|
||||||
# Check Telegram
|
|
||||||
try:
|
|
||||||
from services.telegram_service import telegram_service
|
|
||||||
if telegram_service.is_configured:
|
|
||||||
health_status["services"]["telegram"] = {"status": "healthy"}
|
|
||||||
else:
|
|
||||||
health_status["services"]["telegram"] = {"status": "not_configured"}
|
|
||||||
except Exception as e:
|
|
||||||
health_status["services"]["telegram"] = {
|
|
||||||
"status": "unavailable",
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check VRAM manager
|
|
||||||
try:
|
|
||||||
from services.vram_manager import vram_manager
|
|
||||||
vram_info = vram_manager.get_vram_info()
|
|
||||||
health_status["services"]["vram"] = {
|
|
||||||
"status": "healthy",
|
|
||||||
"available_gb": vram_info.get("free", 0) / (1024**3)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
health_status["services"]["vram"] = {
|
|
||||||
"status": "unavailable",
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return health_status
|
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
"""Inicializa los servicios."""
|
||||||
|
logger.info("Initializing CBCFacil polling service")
|
||||||
|
|
||||||
def initialize_services() -> None:
|
# Verificar configuración
|
||||||
"""Initialize all services with configuration validation"""
|
if not settings.has_webdav_config:
|
||||||
from config import settings
|
logger.error(
|
||||||
from services.webdav_service import webdav_service
|
"WebDAV configuration missing. Set NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD"
|
||||||
from services.vram_manager import vram_manager
|
)
|
||||||
from services.telegram_service import telegram_service
|
sys.exit(1)
|
||||||
from storage.processed_registry import processed_registry
|
|
||||||
|
|
||||||
logger.info("Initializing services...")
|
|
||||||
|
|
||||||
# Validate configuration
|
|
||||||
validate_configuration()
|
|
||||||
|
|
||||||
# Warn if WebDAV not configured
|
|
||||||
if not settings.has_webdav_config:
|
|
||||||
logger.warning("WebDAV not configured - file sync functionality disabled")
|
|
||||||
|
|
||||||
# Warn if AI providers not configured
|
|
||||||
if not settings.has_ai_config:
|
|
||||||
logger.warning("AI providers not configured - summary generation will not work")
|
|
||||||
|
|
||||||
# Configure Telegram if credentials available
|
|
||||||
if settings.TELEGRAM_TOKEN and settings.TELEGRAM_CHAT_ID:
|
|
||||||
try:
|
|
||||||
telegram_service.configure(settings.TELEGRAM_TOKEN, settings.TELEGRAM_CHAT_ID)
|
|
||||||
telegram_service.send_start_notification()
|
|
||||||
logger.info("Telegram notifications enabled")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to configure Telegram: {e}")
|
|
||||||
|
|
||||||
# Initialize WebDAV if configured
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
try:
|
|
||||||
webdav_service.initialize()
|
|
||||||
logger.info("WebDAV service initialized")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to initialize WebDAV: {e}")
|
|
||||||
logger.exception("WebDAV initialization error details")
|
|
||||||
else:
|
|
||||||
logger.info("Skipping WebDAV initialization (not configured)")
|
|
||||||
|
|
||||||
# Initialize VRAM manager
|
|
||||||
try:
|
|
||||||
vram_manager.initialize()
|
|
||||||
logger.info("VRAM manager initialized")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to initialize VRAM manager: {e}")
|
|
||||||
logger.exception("VRAM manager initialization error details")
|
|
||||||
|
|
||||||
# Initialize processed registry
|
|
||||||
try:
|
|
||||||
processed_registry.initialize()
|
|
||||||
logger.info("Processed registry initialized")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to initialize processed registry: {e}")
|
|
||||||
logger.exception("Registry initialization error details")
|
|
||||||
|
|
||||||
# Run health check
|
|
||||||
health = check_service_health()
|
|
||||||
logger.info(f"Initial health check: {json.dumps(health, indent=2)}")
|
|
||||||
|
|
||||||
logger.info("All services initialized successfully")
|
|
||||||
|
|
||||||
|
# Inicializar WebDAV
|
||||||
|
self.webdav_service = WebDAVService()
|
||||||
|
logger.info("Testing WebDAV connection...")
|
||||||
|
if not self.webdav_service.test_connection():
|
||||||
|
logger.error("Failed to connect to Nextcloud")
|
||||||
|
sys.exit(1)
|
||||||
|
logger.info("WebDAV connection successful")
|
||||||
|
|
||||||
def send_error_notification(error_type: str, error_message: str) -> None:
|
# Inicializar TelegramService
|
||||||
"""Send error notification via Telegram"""
|
self._telegram_service = TelegramService()
|
||||||
try:
|
logger.info(
|
||||||
from services.telegram_service import telegram_service
|
"Telegram service initialized",
|
||||||
if telegram_service.is_configured:
|
extra={"configured": self._telegram_service._configured},
|
||||||
telegram_service.send_error_notification(error_type, error_message)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to send error notification: {e}")
|
|
||||||
|
|
||||||
|
# Inicializar ProcessManager con callbacks de Telegram
|
||||||
|
self._process_manager = CoreProcessManager(
|
||||||
|
webdav_service=self.webdav_service,
|
||||||
|
on_state_change=self._on_state_change,
|
||||||
|
on_complete=self._on_process_complete,
|
||||||
|
on_error=self._on_process_error,
|
||||||
|
)
|
||||||
|
logger.info("ProcessManager initialized")
|
||||||
|
|
||||||
def run_main_loop() -> None:
|
# Asignar a variable global para uso en _process_file_async_safe
|
||||||
"""Main processing loop with improved error handling"""
|
global process_manager
|
||||||
from config import settings
|
process_manager = self._process_manager
|
||||||
from services.webdav_service import webdav_service
|
|
||||||
from storage.processed_registry import processed_registry
|
# Inicializar watcher
|
||||||
from processors.audio_processor import AudioProcessor
|
self.watcher = RemoteFolderWatcher(
|
||||||
from processors.pdf_processor import PDFProcessor
|
webdav_service=self.webdav_service,
|
||||||
from processors.text_processor import TextProcessor
|
local_path=settings.DOWNLOADS_DIR,
|
||||||
|
remote_path=settings.WATCHED_REMOTE_PATH,
|
||||||
audio_processor = AudioProcessor()
|
)
|
||||||
pdf_processor = PDFProcessor()
|
self.watcher.set_callback(self._on_file_downloaded)
|
||||||
text_processor = TextProcessor()
|
self.watcher.start()
|
||||||
|
|
||||||
consecutive_errors = 0
|
# Inicializar Flask
|
||||||
max_consecutive_errors = 5
|
self._setup_flask()
|
||||||
|
|
||||||
while True:
|
logger.info("CBCFacil initialized successfully")
|
||||||
try:
|
|
||||||
logger.info("--- Polling for new files ---")
|
def _setup_flask(self) -> None:
|
||||||
processed_registry.load()
|
"""Configura la aplicación Flask."""
|
||||||
|
self.flask_app = Flask(__name__)
|
||||||
# Process PDFs
|
CORS(self.flask_app)
|
||||||
if settings.has_webdav_config:
|
init_api(self._process_manager, self.webdav_service, self.watcher)
|
||||||
try:
|
self.flask_app.register_blueprint(api_bp)
|
||||||
webdav_service.mkdir(settings.REMOTE_PDF_FOLDER)
|
|
||||||
pdf_files = webdav_service.list(settings.REMOTE_PDF_FOLDER)
|
# Ruta principal
|
||||||
for file_path in pdf_files:
|
@self.flask_app.route("/")
|
||||||
if file_path.lower().endswith('.pdf'):
|
def index():
|
||||||
if not processed_registry.is_processed(file_path):
|
return {"message": "CBCFacil Polling Service", "version": "1.0.0"}
|
||||||
pdf_processor.process(file_path)
|
|
||||||
processed_registry.save(file_path)
|
def _on_state_change(self, process_info: ProcessInfo) -> None:
|
||||||
except Exception as e:
|
"""Callback cuando cambia el estado de un proceso."""
|
||||||
logger.exception(f"Error processing PDFs: {e}")
|
filename = process_info.file_path.name
|
||||||
send_error_notification("pdf_processing", str(e))
|
|
||||||
|
# Enviar notificación apropiada según el estado
|
||||||
# Process Audio files
|
if process_info.state.value == "transcribing" and self._telegram_service:
|
||||||
if settings.has_webdav_config:
|
self._telegram_service.send_start_notification(filename)
|
||||||
try:
|
|
||||||
audio_files = webdav_service.list(settings.REMOTE_AUDIOS_FOLDER)
|
def _on_process_complete(self, process_info: ProcessInfo) -> None:
|
||||||
for file_path in audio_files:
|
"""Callback cuando un proceso se completa exitosamente."""
|
||||||
if any(file_path.lower().endswith(ext) for ext in settings.AUDIO_EXTENSIONS):
|
filename = process_info.file_path.name
|
||||||
if not processed_registry.is_processed(file_path):
|
|
||||||
from pathlib import Path
|
if process_info.transcript:
|
||||||
from urllib.parse import unquote
|
logger.info(
|
||||||
from document.generators import DocumentGenerator
|
"Transcripción completada",
|
||||||
from services.telegram_service import telegram_service
|
extra={"file_name": filename, "text_length": len(process_info.transcript)},
|
||||||
|
)
|
||||||
local_filename = unquote(Path(file_path).name)
|
|
||||||
base_name = Path(local_filename).stem
|
# Enviar notificación de completación
|
||||||
local_path = settings.LOCAL_DOWNLOADS_PATH / local_filename
|
if self._telegram_service:
|
||||||
settings.LOCAL_DOWNLOADS_PATH.mkdir(parents=True, exist_ok=True)
|
duration = (process_info.updated_at - process_info.created_at).total_seconds()
|
||||||
|
self._telegram_service.send_completion_notification(
|
||||||
# Step 1: Notify and download
|
filename=filename,
|
||||||
telegram_service.send_message(
|
duration=duration,
|
||||||
f"🎵 Nuevo audio detectado: {local_filename}\n"
|
|
||||||
f"⬇️ Descargando..."
|
|
||||||
)
|
|
||||||
logger.info(f"Downloading audio: {file_path} -> {local_path}")
|
|
||||||
webdav_service.download(file_path, local_path)
|
|
||||||
|
|
||||||
# Step 2: Transcribe
|
|
||||||
telegram_service.send_message(f"📝 Transcribiendo audio con Whisper...")
|
|
||||||
result = audio_processor.process(str(local_path))
|
|
||||||
|
|
||||||
if result.get("success") and result.get("transcription_path"):
|
|
||||||
transcription_file = Path(result["transcription_path"])
|
|
||||||
transcription_text = result.get("text", "")
|
|
||||||
|
|
||||||
# Step 3: Generate AI summary and documents
|
|
||||||
telegram_service.send_message(f"🤖 Generando resumen con IA...")
|
|
||||||
doc_generator = DocumentGenerator()
|
|
||||||
success, summary, output_files = doc_generator.generate_summary(
|
|
||||||
transcription_text, base_name
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 4: Upload all files to Nextcloud
|
|
||||||
if success and output_files:
|
|
||||||
# Create folders
|
|
||||||
for folder in [settings.RESUMENES_FOLDER, settings.DOCX_FOLDER]:
|
|
||||||
try:
|
|
||||||
webdav_service.makedirs(folder)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Upload transcription TXT
|
|
||||||
if transcription_file.exists():
|
|
||||||
remote_txt = f"{settings.RESUMENES_FOLDER}/{transcription_file.name}"
|
|
||||||
webdav_service.upload(transcription_file, remote_txt)
|
|
||||||
logger.info(f"Uploaded: {remote_txt}")
|
|
||||||
|
|
||||||
# Upload DOCX
|
|
||||||
docx_path = Path(output_files.get('docx_path', ''))
|
|
||||||
if docx_path.exists():
|
|
||||||
remote_docx = f"{settings.DOCX_FOLDER}/{docx_path.name}"
|
|
||||||
webdav_service.upload(docx_path, remote_docx)
|
|
||||||
logger.info(f"Uploaded: {remote_docx}")
|
|
||||||
|
|
||||||
# Upload PDF
|
|
||||||
pdf_path = Path(output_files.get('pdf_path', ''))
|
|
||||||
if pdf_path.exists():
|
|
||||||
remote_pdf = f"{settings.DOCX_FOLDER}/{pdf_path.name}"
|
|
||||||
webdav_service.upload(pdf_path, remote_pdf)
|
|
||||||
logger.info(f"Uploaded: {remote_pdf}")
|
|
||||||
|
|
||||||
# Upload Markdown
|
|
||||||
md_path = Path(output_files.get('markdown_path', ''))
|
|
||||||
if md_path.exists():
|
|
||||||
remote_md = f"{settings.RESUMENES_FOLDER}/{md_path.name}"
|
|
||||||
webdav_service.upload(md_path, remote_md)
|
|
||||||
logger.info(f"Uploaded: {remote_md}")
|
|
||||||
|
|
||||||
# Final notification
|
|
||||||
telegram_service.send_message(
|
|
||||||
f"✅ Audio procesado: {local_filename}\n"
|
|
||||||
f"📄 DOCX: {docx_path.name if docx_path.exists() else 'N/A'}\n"
|
|
||||||
f"📑 PDF: {pdf_path.name if pdf_path.exists() else 'N/A'}\n"
|
|
||||||
f"☁️ Subido a Nextcloud"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Just upload transcription if summary failed
|
|
||||||
if transcription_file.exists():
|
|
||||||
try:
|
|
||||||
webdav_service.makedirs(settings.RESUMENES_FOLDER)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
remote_txt = f"{settings.RESUMENES_FOLDER}/{transcription_file.name}"
|
|
||||||
webdav_service.upload(transcription_file, remote_txt)
|
|
||||||
telegram_service.send_message(
|
|
||||||
f"⚠️ Resumen fallido, solo transcripción subida:\n{transcription_file.name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
processed_registry.save(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error processing audio: {e}")
|
|
||||||
send_error_notification("audio_processing", str(e))
|
|
||||||
|
|
||||||
# Process Text files
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
try:
|
|
||||||
text_files = webdav_service.list(settings.REMOTE_TXT_FOLDER)
|
|
||||||
for file_path in text_files:
|
|
||||||
if any(file_path.lower().endswith(ext) for ext in settings.TXT_EXTENSIONS):
|
|
||||||
if not processed_registry.is_processed(file_path):
|
|
||||||
text_processor.process(file_path)
|
|
||||||
processed_registry.save(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error processing text: {e}")
|
|
||||||
send_error_notification("text_processing", str(e))
|
|
||||||
|
|
||||||
# Reset error counter on success
|
|
||||||
consecutive_errors = 0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Improved error logging with full traceback
|
|
||||||
logger.exception(f"Critical error in main loop: {e}")
|
|
||||||
|
|
||||||
# Send notification for critical errors
|
|
||||||
send_error_notification("main_loop", str(e))
|
|
||||||
|
|
||||||
# Track consecutive errors
|
|
||||||
consecutive_errors += 1
|
|
||||||
|
|
||||||
if consecutive_errors >= max_consecutive_errors:
|
|
||||||
logger.critical(
|
|
||||||
f"Too many consecutive errors ({consecutive_errors}). "
|
|
||||||
"Service may be unstable. Consider checking configuration."
|
|
||||||
)
|
)
|
||||||
send_error_notification(
|
|
||||||
"consecutive_errors",
|
def _on_process_error(self, process_info: ProcessInfo) -> None:
|
||||||
f"Service has failed {consecutive_errors} consecutive times"
|
"""Callback cuando ocurre un error en un proceso."""
|
||||||
|
filename = process_info.file_path.name
|
||||||
|
error_msg = process_info.error or "Unknown error"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Transcripción fallida",
|
||||||
|
extra={"file": filename, "error": error_msg},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enviar notificación de error
|
||||||
|
if self._telegram_service:
|
||||||
|
self._telegram_service.send_error_notification(filename, error_msg)
|
||||||
|
|
||||||
|
def _on_file_downloaded(self, file_path: Path) -> None:
|
||||||
|
"""Callback when a file is downloaded."""
|
||||||
|
self.queue_file_for_processing(file_path)
|
||||||
|
|
||||||
|
def queue_file_for_processing(self, file_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Agrega un archivo a la cola de procesamiento SECUENCIAL.
|
||||||
|
Solo UN archivo se procesa a la vez.
|
||||||
|
"""
|
||||||
|
# Intentar iniciar procesamiento inmediatamente
|
||||||
|
if monitor.start_processing(file_path):
|
||||||
|
# Se pudo iniciar - procesar este archivo
|
||||||
|
logger.info(
|
||||||
|
"🚀 Starting IMMEDIATE processing (SOLE file)",
|
||||||
|
extra={"file": file_path.name, "queue_size": monitor.get_queue_size()}
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_wrapper():
|
||||||
|
try:
|
||||||
|
_process_file_async_safe(file_path)
|
||||||
|
finally:
|
||||||
|
# Siempre limpiar y continuar con el siguiente
|
||||||
|
monitor.finish_processing()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=process_wrapper, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
else:
|
||||||
|
# Ya hay un archivo en proceso - agregar a la cola
|
||||||
|
if monitor.add_to_queue(file_path):
|
||||||
|
logger.info(
|
||||||
|
"⏳ File QUEUED (waiting for current to finish)",
|
||||||
|
extra={
|
||||||
|
"file": file_path.name,
|
||||||
|
"queue_position": monitor.get_queue_size(),
|
||||||
|
"currently_processing": monitor.get_current_file()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
# Don't exit, let the loop continue with backoff
|
logger.debug(f"File already in queue: {file_path.name}")
|
||||||
logger.info(f"Waiting {settings.POLL_INTERVAL * 2} seconds before retry...")
|
|
||||||
time.sleep(settings.POLL_INTERVAL * 2)
|
def run(self) -> None:
|
||||||
continue
|
"""Ejecuta el servicio."""
|
||||||
|
self._running = True
|
||||||
logger.info(f"Cycle completed. Waiting {settings.POLL_INTERVAL} seconds...")
|
|
||||||
time.sleep(settings.POLL_INTERVAL)
|
# Iniciar Flask en un hilo separado
|
||||||
|
flask_thread = threading.Thread(
|
||||||
|
target=self._run_flask,
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
flask_thread.start()
|
||||||
|
logger.info(
|
||||||
|
"Flask server started",
|
||||||
|
extra={
|
||||||
|
"host": settings.DASHBOARD_HOST,
|
||||||
|
"port": settings.DASHBOARD_PORT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Procesar archivos pendientes
|
||||||
|
self._process_pending_files()
|
||||||
|
|
||||||
|
# Loop principal de polling
|
||||||
|
logger.info("Starting main polling loop")
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
time.sleep(settings.POLL_INTERVAL)
|
||||||
|
self.watcher.check_now()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received shutdown signal")
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def _run_flask(self) -> None:
|
||||||
|
"""Ejecuta la aplicación Flask."""
|
||||||
|
logger.info("Starting Flask development server")
|
||||||
|
self.flask_app.run(
|
||||||
|
host=settings.DASHBOARD_HOST,
|
||||||
|
port=settings.DASHBOARD_PORT,
|
||||||
|
debug=False,
|
||||||
|
use_reloader=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_pending_files(self) -> None:
|
||||||
|
"""Procesa archivos pendientes en la carpeta de descargas."""
|
||||||
|
if self._process_manager is None:
|
||||||
|
logger.warning("ProcessManager not initialized, skipping pending files")
|
||||||
|
return
|
||||||
|
|
||||||
|
downloads_dir = settings.DOWNLOADS_DIR
|
||||||
|
if not downloads_dir.exists():
|
||||||
|
logger.debug("Downloads directory does not exist")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extensiones de audio soportadas
|
||||||
|
audio_extensions = {".mp3", ".wav", ".m4a", ".mp4", ".webm", ".ogg", ".flac"}
|
||||||
|
|
||||||
|
pending_files = [
|
||||||
|
f for f in downloads_dir.iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() in audio_extensions and not f.name.startswith(".")
|
||||||
|
]
|
||||||
|
|
||||||
|
if not pending_files:
|
||||||
|
logger.debug("No pending audio files to process")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Found {len(pending_files)} pending audio files",
|
||||||
|
extra={"count": len(pending_files)},
|
||||||
|
)
|
||||||
|
|
||||||
|
for file_path in pending_files:
|
||||||
|
logger.info(f"Processing pending file: {file_path.name}")
|
||||||
|
self.queue_file_for_processing(file_path)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Detiene el servicio."""
|
||||||
|
self._running = False
|
||||||
|
if self.watcher:
|
||||||
|
self.watcher.stop()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
# ============================================================================
|
||||||
"""Main entry point"""
|
# Función segura de procesamiento de archivos
|
||||||
lock_fd = None
|
# ============================================================================
|
||||||
|
|
||||||
|
def _process_file_async_safe(file_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Procesa un archivo de forma asíncrona.
|
||||||
|
|
||||||
|
El cleanup y continuación de la cola se maneja en los wrappers
|
||||||
|
(process_wrapper en queue_file_for_processing y _process_next).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("=== CBCFacil Service Started ===")
|
if process_manager is None:
|
||||||
logger.info(f"Version: {os.getenv('APP_VERSION', '8.0')}")
|
logger.error("ProcessManager not initialized")
|
||||||
logger.info(f"Environment: {'production' if os.getenv('DEBUG', 'false').lower() != 'true' else 'development'}")
|
return
|
||||||
|
|
||||||
lock_fd = acquire_lock()
|
logger.info(
|
||||||
initialize_services()
|
"Starting file processing",
|
||||||
run_main_loop()
|
extra={"file": file_path.name},
|
||||||
|
)
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("Shutdown requested by user")
|
# Procesar el archivo
|
||||||
|
process_manager.process_file(file_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Fatal error in main: {e}")
|
logger.exception(
|
||||||
send_error_notification("fatal_error", str(e))
|
"Error processing file",
|
||||||
sys.exit(1)
|
extra={
|
||||||
finally:
|
"file_name": file_path.name,
|
||||||
if lock_fd:
|
"error": str(e),
|
||||||
release_lock(lock_fd)
|
"error_type": type(e).__name__,
|
||||||
logger.info("=== CBCFacil Service Stopped ===")
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Variable global para el ProcessManager
|
||||||
|
process_manager = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Punto de entrada principal."""
|
||||||
|
try:
|
||||||
|
service = PollingService()
|
||||||
|
service.initialize()
|
||||||
|
service.run()
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Fatal error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Handle CLI commands
|
sys.exit(main())
|
||||||
if len(sys.argv) > 1:
|
|
||||||
command = sys.argv[1]
|
|
||||||
if command == "whisper" and len(sys.argv) == 4:
|
|
||||||
from processors.audio_processor import AudioProcessor
|
|
||||||
AudioProcessor().process(sys.argv[2])
|
|
||||||
elif command == "pdf" and len(sys.argv) == 4:
|
|
||||||
from processors.pdf_processor import PDFProcessor
|
|
||||||
PDFProcessor().process(sys.argv[2])
|
|
||||||
elif command == "health":
|
|
||||||
from main import check_service_health
|
|
||||||
health = check_service_health()
|
|
||||||
print(json.dumps(health, indent=2))
|
|
||||||
else:
|
|
||||||
print("Usage: python main.py [whisper|pdf|health]")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
main()
|
|
||||||
|
|||||||
148
main.py.backup
148
main.py.backup
@@ -1,148 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
CBCFacil - Main Service Entry Point
|
|
||||||
Unified AI service for document processing (audio, PDF, text)
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import fcntl
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] - %(message)s"
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_lock() -> int:
|
|
||||||
"""Acquire single instance lock"""
|
|
||||||
lock_file = Path(os.getenv("LOCAL_STATE_DIR", str(Path(__file__).parent))) / ".main_service.lock"
|
|
||||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lock_fd = open(lock_file, 'w')
|
|
||||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
lock_fd.write(str(os.getpid()))
|
|
||||||
lock_fd.flush()
|
|
||||||
logger.info(f"Lock acquired. PID: {os.getpid()}")
|
|
||||||
return lock_fd
|
|
||||||
|
|
||||||
|
|
||||||
def release_lock(lock_fd) -> None:
|
|
||||||
"""Release lock"""
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
|
|
||||||
lock_fd.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not release lock: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_services() -> None:
|
|
||||||
"""Initialize all services"""
|
|
||||||
from config import settings
|
|
||||||
from services.webdav_service import webdav_service
|
|
||||||
from services.vram_manager import vram_manager
|
|
||||||
from services.telegram_service import telegram_service
|
|
||||||
from storage.processed_registry import processed_registry
|
|
||||||
|
|
||||||
# Configure Telegram if credentials available
|
|
||||||
if settings.TELEGRAM_TOKEN and settings.TELEGRAM_CHAT_ID:
|
|
||||||
telegram_service.configure(settings.TELEGRAM_TOKEN, settings.TELEGRAM_CHAT_ID)
|
|
||||||
telegram_service.send_start_notification()
|
|
||||||
|
|
||||||
# Initialize WebDAV if configured
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
webdav_service.initialize()
|
|
||||||
|
|
||||||
# Initialize VRAM manager
|
|
||||||
vram_manager.initialize()
|
|
||||||
|
|
||||||
# Initialize processed registry
|
|
||||||
processed_registry.initialize()
|
|
||||||
|
|
||||||
logger.info("All services initialized")
|
|
||||||
|
|
||||||
|
|
||||||
def run_main_loop() -> None:
|
|
||||||
"""Main processing loop"""
|
|
||||||
from config import settings
|
|
||||||
from services.webdav_service import webdav_service
|
|
||||||
from storage.processed_registry import processed_registry
|
|
||||||
from processors.audio_processor import AudioProcessor
|
|
||||||
from processors.pdf_processor import PDFProcessor
|
|
||||||
from processors.text_processor import TextProcessor
|
|
||||||
|
|
||||||
audio_processor = AudioProcessor()
|
|
||||||
pdf_processor = PDFProcessor()
|
|
||||||
text_processor = TextProcessor()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
logger.info("--- Polling for new files ---")
|
|
||||||
processed_registry.load()
|
|
||||||
|
|
||||||
# Process PDFs
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
webdav_service.mkdir(settings.REMOTE_PDF_FOLDER)
|
|
||||||
pdf_files = webdav_service.list(settings.REMOTE_PDF_FOLDER)
|
|
||||||
for file_path in pdf_files:
|
|
||||||
if file_path.lower().endswith('.pdf'):
|
|
||||||
if not processed_registry.is_processed(file_path):
|
|
||||||
pdf_processor.process(file_path)
|
|
||||||
processed_registry.save(file_path)
|
|
||||||
|
|
||||||
# Process Audio files
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
audio_files = webdav_service.list(settings.REMOTE_AUDIOS_FOLDER)
|
|
||||||
for file_path in audio_files:
|
|
||||||
if any(file_path.lower().endswith(ext) for ext in settings.AUDIO_EXTENSIONS):
|
|
||||||
if not processed_registry.is_processed(file_path):
|
|
||||||
audio_processor.process(file_path)
|
|
||||||
processed_registry.save(file_path)
|
|
||||||
|
|
||||||
# Process Text files
|
|
||||||
if settings.has_webdav_config:
|
|
||||||
text_files = webdav_service.list(settings.REMOTE_TXT_FOLDER)
|
|
||||||
for file_path in text_files:
|
|
||||||
if any(file_path.lower().endswith(ext) for ext in settings.TXT_EXTENSIONS):
|
|
||||||
if not processed_registry.is_processed(file_path):
|
|
||||||
text_processor.process(file_path)
|
|
||||||
processed_registry.save(file_path)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in main loop: {e}")
|
|
||||||
|
|
||||||
logger.info(f"Cycle completed. Waiting {settings.POLL_INTERVAL} seconds...")
|
|
||||||
time.sleep(settings.POLL_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point"""
|
|
||||||
lock_fd = acquire_lock()
|
|
||||||
try:
|
|
||||||
logger.info("=== CBCFacil Service Started ===")
|
|
||||||
initialize_services()
|
|
||||||
run_main_loop()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("Shutdown requested")
|
|
||||||
finally:
|
|
||||||
release_lock(lock_fd)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Handle CLI commands
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
command = sys.argv[1]
|
|
||||||
if command == "whisper" and len(sys.argv) == 4:
|
|
||||||
from processors.audio_processor import AudioProcessor
|
|
||||||
AudioProcessor().process(sys.argv[2])
|
|
||||||
elif command == "pdf" and len(sys.argv) == 4:
|
|
||||||
from processors.pdf_processor import PDFProcessor
|
|
||||||
PDFProcessor().process(sys.argv[2])
|
|
||||||
else:
|
|
||||||
print("Usage: python main.py [whisper|pdf]")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
main()
|
|
||||||
@@ -1,15 +1,5 @@
|
|||||||
"""
|
"""Procesadores de documentos y medios."""
|
||||||
Processors package for CBCFacil
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .base_processor import FileProcessor
|
from processors.audio_processor import AudioProcessor, AudioProcessingError
|
||||||
from .audio_processor import AudioProcessor
|
|
||||||
from .pdf_processor import PDFProcessor
|
|
||||||
from .text_processor import TextProcessor
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["AudioProcessor", "AudioProcessingError"]
|
||||||
'FileProcessor',
|
|
||||||
'AudioProcessor',
|
|
||||||
'PDFProcessor',
|
|
||||||
'TextProcessor'
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,93 +1,467 @@
|
|||||||
"""
|
"""
|
||||||
Audio file processor using Whisper
|
Procesador de audio para transcripción con Whisper.
|
||||||
|
|
||||||
|
OPTIMIZACIONES DE MEMORIA PARA GPUs DE 8GB:
|
||||||
|
- Cache global singleton para evitar carga múltiple del modelo
|
||||||
|
- Configuración PYTORCH_ALLOC_CONF para reducir fragmentación
|
||||||
|
- Verificación de VRAM antes de cargar
|
||||||
|
- Fallback automático a CPU si GPU OOM
|
||||||
|
- Limpieza agresiva de cache CUDA
|
||||||
"""
|
"""
|
||||||
|
import gc
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Literal, Optional, Tuple
|
||||||
from core import FileProcessingError
|
|
||||||
|
import whisper
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from services import vram_manager
|
from services.vram_manager import vram_manager
|
||||||
from services.gpu_detector import gpu_detector
|
|
||||||
from .base_processor import FileProcessor
|
|
||||||
|
|
||||||
try:
|
|
||||||
import whisper
|
|
||||||
import torch
|
|
||||||
WHISPER_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
WHISPER_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
class AudioProcessor(FileProcessor):
|
logger = logging.getLogger(__name__)
|
||||||
"""Processor for audio files using Whisper"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__("AudioProcessor")
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._model = None
|
|
||||||
self._model_name = "medium" # Optimized for Spanish
|
|
||||||
|
|
||||||
def can_process(self, file_path: str) -> bool:
|
# ============ CONFIGURACIÓN DE OPTIMIZACIONES ============
|
||||||
"""Check if file is an audio file"""
|
|
||||||
ext = self.get_file_extension(file_path)
|
|
||||||
return ext in settings.AUDIO_EXTENSIONS
|
|
||||||
|
|
||||||
def _load_model(self):
|
# CRÍTICO: Permite segmentos expandibles para reducir fragmentación
|
||||||
"""Load Whisper model lazily"""
|
os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
|
||||||
if not WHISPER_AVAILABLE:
|
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
||||||
raise FileProcessingError("Whisper not installed")
|
|
||||||
|
|
||||||
if self._model is None:
|
# Tamaños de modelos en GB (incluyendo overhead)
|
||||||
device = gpu_detector.get_device()
|
MODEL_MEMORY_REQUIREMENTS = {
|
||||||
self.logger.info(f"Loading Whisper model: {self._model_name} on {device}")
|
"tiny": 0.5, "base": 0.8, "small": 1.5,
|
||||||
self._model = whisper.load_model(self._model_name, device=device)
|
"medium": 2.5, "large": 4.5,
|
||||||
vram_manager.update_usage()
|
}
|
||||||
|
|
||||||
def process(self, file_path: str) -> Dict[str, Any]:
|
# Cache global singleton - CLAVE para evitar OOM
|
||||||
"""Transcribe audio file"""
|
_model_cache: Dict[str, Tuple[whisper.Whisper, str, float]] = {}
|
||||||
self.validate_file(file_path)
|
|
||||||
audio_path = Path(file_path)
|
|
||||||
output_path = settings.LOCAL_DOWNLOADS_PATH / f"{audio_path.stem}.txt"
|
|
||||||
|
|
||||||
self.logger.info(f"Processing audio file: {audio_path}")
|
TRANSCRIPTION_TIMEOUT_SECONDS = 600
|
||||||
|
MAX_RETRY_ATTEMPTS = 2
|
||||||
|
RETRY_DELAY_SECONDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
# ============ FUNCIONES DE GESTIÓN DE MEMORIA ============
|
||||||
|
|
||||||
|
def get_gpu_memory_info() -> Dict[str, float]:
|
||||||
|
"""Obtiene información de memoria GPU en GB."""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
props = torch.cuda.get_device_properties(0)
|
||||||
|
total = props.total_memory / (1024 ** 3)
|
||||||
|
reserved = torch.cuda.memory_reserved(0) / (1024 ** 3)
|
||||||
|
allocated = torch.cuda.memory_allocated(0) / (1024 ** 3)
|
||||||
|
return {"total": total, "free": total - reserved, "used": allocated, "reserved": reserved}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"total": 0, "free": 0, "used": 0, "reserved": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cuda_cache(aggressive: bool = False) -> None:
|
||||||
|
"""Limpia el cache de CUDA."""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
if aggressive:
|
||||||
|
for _ in range(3):
|
||||||
|
gc.collect()
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check_memory_for_model(model_name: str) -> Tuple[bool, str]:
|
||||||
|
"""Verifica si hay memoria suficiente para el modelo."""
|
||||||
|
required = MODEL_MEMORY_REQUIREMENTS.get(model_name, 2.0)
|
||||||
|
gpu_info = get_gpu_memory_info()
|
||||||
|
|
||||||
|
if gpu_info["total"] == 0:
|
||||||
|
return False, "cpu"
|
||||||
|
|
||||||
|
needed = required * 1.5
|
||||||
|
if gpu_info["free"] >= needed:
|
||||||
|
return True, "cuda"
|
||||||
|
elif gpu_info["free"] >= required:
|
||||||
|
return True, "cuda"
|
||||||
|
else:
|
||||||
|
logger.warning(f"Memoria GPU insuficiente para '{model_name}': {gpu_info['free']:.2f}GB libre, {required:.2f}GB necesario")
|
||||||
|
return False, "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_model(model_name: str, device: str) -> Optional[whisper.Whisper]:
|
||||||
|
"""Obtiene modelo desde cache global."""
|
||||||
|
cache_key = f"{model_name}_{device}"
|
||||||
|
if cache_key in _model_cache:
|
||||||
|
model, cached_device, _ = _model_cache[cache_key]
|
||||||
|
if cached_device == device:
|
||||||
|
logger.info(f"Modelo '{model_name}' desde cache global")
|
||||||
|
_model_cache[cache_key] = (model, cached_device, time.time())
|
||||||
|
return model
|
||||||
|
del _model_cache[cache_key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cache_model(model_name: str, model: whisper.Whisper, device: str) -> None:
|
||||||
|
"""Almacena modelo en cache global."""
|
||||||
|
cache_key = f"{model_name}_{device}"
|
||||||
|
_model_cache[cache_key] = (model, device, time.time())
|
||||||
|
logger.info(f"Modelo '{model_name}' cacheado en {device}")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_model_cache() -> None:
|
||||||
|
"""Limpia todo el cache de modelos."""
|
||||||
|
global _model_cache
|
||||||
|
for cache_key, (model, _, _) in list(_model_cache.items()):
|
||||||
|
try:
|
||||||
|
del model
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_model_cache.clear()
|
||||||
|
clear_cuda_cache(aggressive=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ EXCEPCIONES ============
|
||||||
|
|
||||||
|
class AudioProcessingError(Exception):
|
||||||
|
"""Error específico para fallos en el procesamiento de audio."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionTimeoutError(AudioProcessingError):
|
||||||
|
"""Error cuando la transcripción excede el tiempo máximo."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GPUOutOfMemoryError(AudioProcessingError):
|
||||||
|
"""Error específico para CUDA OOM."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AudioValidationError(AudioProcessingError):
|
||||||
|
"""Error cuando el archivo de audio no pasa las validaciones."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============ PROCESADOR DE AUDIO ============
|
||||||
|
|
||||||
|
class AudioProcessor:
|
||||||
|
"""Procesador de audio con cache global y fallback automático."""
|
||||||
|
|
||||||
|
SUPPORTED_MODELS = ("tiny", "base", "small", "medium", "large")
|
||||||
|
DEFAULT_MODEL = settings.WHISPER_MODEL
|
||||||
|
DEFAULT_LANGUAGE = "es"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_name: Optional[str] = None,
|
||||||
|
language: Optional[str] = None,
|
||||||
|
device: Optional[Literal["cuda", "rocm", "cpu", "auto"]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._model_name = model_name or settings.WHISPER_MODEL
|
||||||
|
self._language = language or self.DEFAULT_LANGUAGE
|
||||||
|
self._device = device or "auto"
|
||||||
|
self._model: Optional[whisper.Whisper] = None
|
||||||
|
self._using_cpu_fallback = False
|
||||||
|
self._model_id = f"whisper_{self._model_name}"
|
||||||
|
|
||||||
|
if self._model_name not in self.SUPPORTED_MODELS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Modelo '{self._model_name}' no soportado. "
|
||||||
|
f"Disponibles: {', '.join(self.SUPPORTED_MODELS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"AudioProcessor inicializado",
|
||||||
|
extra={"model": self._model_name, "device": self._device},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_name(self) -> str:
|
||||||
|
return self._model_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def language(self) -> str:
|
||||||
|
return self._language
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> str:
|
||||||
|
return getattr(self, "_resolved_device", self._device)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
return self._model is not None
|
||||||
|
|
||||||
|
def _validate_audio_file(self, audio_path: Path) -> dict:
|
||||||
|
"""Valida el archivo de audio."""
|
||||||
|
logger.info(f"Validando: {audio_path.name}")
|
||||||
|
|
||||||
|
file_size = audio_path.stat().st_size
|
||||||
|
if file_size < 1024:
|
||||||
|
raise AudioValidationError("Archivo demasiado pequeño")
|
||||||
|
if file_size > 500 * 1024 * 1024:
|
||||||
|
logger.warning(f"Archivo grande: {file_size / 1024 / 1024:.1f}MB")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load model if needed
|
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||||
self._load_model()
|
"-show_entries", "stream=channels,sample_rate,codec_name",
|
||||||
|
"-of", "json", str(audio_path)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
import json
|
||||||
|
info = json.loads(result.stdout)
|
||||||
|
duration = float(info.get("format", {}).get("duration", 0))
|
||||||
|
|
||||||
|
for stream in info.get("streams", []):
|
||||||
|
if stream.get("codec_type") == "audio":
|
||||||
|
return {
|
||||||
|
"duration": duration,
|
||||||
|
"sample_rate": int(stream.get("sample_rate", 16000)),
|
||||||
|
"channels": int(stream.get("channels", 1)),
|
||||||
|
"codec": stream.get("codec_name", "unknown"),
|
||||||
|
"size_bytes": file_size,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Update VRAM usage
|
return {"duration": 0, "sample_rate": 16000, "channels": 1,
|
||||||
vram_manager.update_usage()
|
"codec": "unknown", "size_bytes": file_size}
|
||||||
|
|
||||||
# Transcribe with torch.no_grad() for memory efficiency
|
def _convert_audio_with_ffmpeg(self, input_path: Path, output_format: str = "wav") -> Path:
|
||||||
with torch.inference_mode():
|
"""Convierte audio usando ffmpeg."""
|
||||||
result = self._model.transcribe(
|
suffix = f".{output_format}"
|
||||||
str(audio_path),
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||||
language="es",
|
output_path = Path(tmp.name)
|
||||||
fp16=True,
|
|
||||||
verbose=False
|
cmd = ["ffmpeg", "-i", str(input_path),
|
||||||
|
"-acodec", "pcm_s16le" if output_format == "wav" else "libmp3lame",
|
||||||
|
"-ar", "16000", "-ac", "1", "-y", str(output_path)]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
|
||||||
|
if result.returncode != 0 or not output_path.exists():
|
||||||
|
raise AudioProcessingError(f"ffmpeg falló: {result.stderr[-500:] if result.stderr else 'Unknown'}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def _get_device_with_memory_check(self) -> str:
|
||||||
|
"""Detecta dispositivo verificando memoria disponible."""
|
||||||
|
if self._device == "cpu":
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
if self._device == "auto":
|
||||||
|
has_memory, recommended = check_memory_for_model(self._model_name)
|
||||||
|
|
||||||
|
if has_memory and recommended == "cuda":
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
logger.info(f"GPU detectada: {torch.cuda.get_device_name(0)}")
|
||||||
|
return "cuda"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not has_memory:
|
||||||
|
logger.warning("Usando CPU por falta de memoria GPU")
|
||||||
|
self._using_cpu_fallback = True
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
def _load_model(self, force_reload: bool = False) -> None:
|
||||||
|
"""Carga modelo usando cache global con optimizaciones de memoria."""
|
||||||
|
if self._model is not None and not force_reload:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Configurar PyTorch para mejor gestión de memoria
|
||||||
|
import os
|
||||||
|
os.environ['PYTORCH_ALLOC_CONF'] = 'expandable_segments:True'
|
||||||
|
|
||||||
|
clear_cuda_cache(aggressive=True)
|
||||||
|
self._resolved_device = self._get_device_with_memory_check()
|
||||||
|
|
||||||
|
# Verificar cache global
|
||||||
|
if not force_reload:
|
||||||
|
cached = get_cached_model(self._model_name, self._resolved_device)
|
||||||
|
if cached is not None:
|
||||||
|
self._model = cached
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Cargar modelo con menos memoria inicial
|
||||||
|
# Primero cargar en RAM, luego mover a GPU
|
||||||
|
import torch
|
||||||
|
with torch.cuda.device(self._resolved_device):
|
||||||
|
self._model = whisper.load_model(
|
||||||
|
self._model_name,
|
||||||
|
device=self._resolved_device,
|
||||||
|
download_root=None,
|
||||||
|
in_memory=True # Reducir uso de disco
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save transcription
|
# Limpiar cache después de cargar
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
torch.cuda.empty_cache()
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(result["text"])
|
|
||||||
|
|
||||||
self.logger.info(f"Transcription completed: {output_path}")
|
cache_model(self._model_name, self._model, self._resolved_device)
|
||||||
|
|
||||||
return {
|
gpu_info = get_gpu_memory_info()
|
||||||
"success": True,
|
logger.info(
|
||||||
"transcription_path": str(output_path),
|
f"Modelo cargado en {self._resolved_device}",
|
||||||
"text": result["text"],
|
extra={"gpu_used_gb": round(gpu_info.get("used", 0), 2),
|
||||||
"model_used": self._model_name
|
"gpu_free_gb": round(gpu_info.get("free", 0), 2)},
|
||||||
}
|
)
|
||||||
|
vram_manager.update_usage(self._model_id)
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
error_str = str(e)
|
||||||
|
if "out of memory" in error_str.lower():
|
||||||
|
# NUNCA usar CPU - limpiar GPU y reintentar
|
||||||
|
logger.error(f"OOM en GPU - limpiando memoria para reintentar...")
|
||||||
|
clear_cuda_cache(aggressive=True)
|
||||||
|
raise AudioProcessingError(f"CUDA OOM - limpie la GPU y reintente. {error_str}") from e
|
||||||
|
else:
|
||||||
|
raise AudioProcessingError(f"Error cargando modelo: {e}") from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Audio processing failed: {e}")
|
raise AudioProcessingError(f"Error cargando modelo: {e}") from e
|
||||||
raise FileProcessingError(f"Audio processing failed: {e}")
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def _transcribe_internal(self, audio_path: Path, audio_properties: dict) -> str:
|
||||||
"""Cleanup model"""
|
"""Ejecuta la transcripción real."""
|
||||||
|
result = self._model.transcribe(
|
||||||
|
str(audio_path),
|
||||||
|
language=self._language,
|
||||||
|
fp16=self._resolved_device in ("cuda", "rocm"),
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
return result.get("text", "").strip()
|
||||||
|
|
||||||
|
def transcribe(self, audio_path: str) -> str:
|
||||||
|
"""Transcribe un archivo de audio."""
|
||||||
|
audio_file = Path(audio_path)
|
||||||
|
if not audio_file.exists():
|
||||||
|
raise FileNotFoundError(f"Archivo no encontrado: {audio_path}")
|
||||||
|
|
||||||
|
vram_manager.update_usage(self._model_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_properties = self._validate_audio_file(audio_file)
|
||||||
|
except AudioValidationError as e:
|
||||||
|
logger.error(f"Validación falló: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
converted_file: Optional[Path] = None
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRY_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
force_reload = attempt > 0
|
||||||
|
if self._model is None or force_reload:
|
||||||
|
self._load_model(force_reload=force_reload)
|
||||||
|
|
||||||
|
audio_to_transcribe = audio_file
|
||||||
|
cleanup_converted = False
|
||||||
|
|
||||||
|
needs_conversion = (
|
||||||
|
audio_file.suffix.lower() not in {".wav", ".mp3"} or
|
||||||
|
audio_properties.get("codec") in ("aac", "opus", "vorbis") or
|
||||||
|
audio_properties.get("channels", 1) > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_conversion:
|
||||||
|
try:
|
||||||
|
converted_file = self._convert_audio_with_ffmpeg(audio_file, "wav")
|
||||||
|
audio_to_transcribe = converted_file
|
||||||
|
cleanup_converted = True
|
||||||
|
except AudioProcessingError as e:
|
||||||
|
logger.warning(f"Conversión falló: {e}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Transcribiendo: {audio_file.name}",
|
||||||
|
extra={"device": self._resolved_device, "cpu_fallback": self._using_cpu_fallback},
|
||||||
|
)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||||
|
future = executor.submit(self._transcribe_internal, audio_to_transcribe, audio_properties)
|
||||||
|
try:
|
||||||
|
text = future.result(timeout=TRANSCRIPTION_TIMEOUT_SECONDS)
|
||||||
|
except FutureTimeoutError:
|
||||||
|
self.unload()
|
||||||
|
raise TranscriptionTimeoutError(f"Timeout después de {TRANSCRIPTION_TIMEOUT_SECONDS}s")
|
||||||
|
|
||||||
|
logger.info(f"Transcripción completada: {len(text)} caracteres")
|
||||||
|
return text
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
error_str = str(e)
|
||||||
|
last_error = e
|
||||||
|
|
||||||
|
if "out of memory" in error_str.lower():
|
||||||
|
logger.warning("OOM durante transcripción...")
|
||||||
|
clear_cuda_cache(aggressive=True)
|
||||||
|
|
||||||
|
if not self._using_cpu_fallback and self._resolved_device in ("cuda", "rocm"):
|
||||||
|
self.unload()
|
||||||
|
self._resolved_device = "cpu"
|
||||||
|
self._using_cpu_fallback = True
|
||||||
|
self._load_model()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if attempt >= MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
raise GPUOutOfMemoryError("Memoria GPU insuficiente") from e
|
||||||
|
time.sleep(RETRY_DELAY_SECONDS)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "Key and Value must have the same sequence length" in error_str:
|
||||||
|
if not converted_file:
|
||||||
|
converted_file = self._convert_audio_with_ffmpeg(audio_file, "wav")
|
||||||
|
text = self._model.transcribe(
|
||||||
|
str(converted_file), language=self._language,
|
||||||
|
fp16=self._resolved_device in ("cuda", "rocm"), verbose=False
|
||||||
|
).get("text", "").strip()
|
||||||
|
converted_file.unlink()
|
||||||
|
return text
|
||||||
|
|
||||||
|
raise AudioProcessingError(f"Error de transcripción: {e}") from e
|
||||||
|
|
||||||
|
except (TranscriptionTimeoutError, GPUOutOfMemoryError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
self.unload()
|
||||||
|
|
||||||
|
if attempt >= MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
raise AudioProcessingError(f"Error después de {MAX_RETRY_ATTEMPTS} intentos: {e}") from e
|
||||||
|
|
||||||
|
time.sleep(RETRY_DELAY_SECONDS)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if converted_file and converted_file.exists():
|
||||||
|
try:
|
||||||
|
converted_file.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise AudioProcessingError(f"Error al transcribir: {last_error}") from last_error
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
"""Descarga la referencia local del modelo."""
|
||||||
if self._model is not None:
|
if self._model is not None:
|
||||||
del self._model
|
|
||||||
self._model = None
|
self._model = None
|
||||||
vram_manager.cleanup()
|
clear_cuda_cache(aggressive=False)
|
||||||
|
vram_manager.unregister_model(self._model_id)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"AudioProcessor(model='{self._model_name}', device='{self.device}', loaded={self.is_loaded})"
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
try:
|
||||||
|
self.unload()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
"""
|
|
||||||
Base File Processor (Strategy Pattern)
|
|
||||||
"""
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from core import FileProcessingError
|
|
||||||
|
|
||||||
|
|
||||||
class FileProcessor(ABC):
|
|
||||||
"""Abstract base class for file processors"""
|
|
||||||
|
|
||||||
def __init__(self, name: str):
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def can_process(self, file_path: str) -> bool:
|
|
||||||
"""Check if processor can handle this file type"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def process(self, file_path: str) -> Dict[str, Any]:
|
|
||||||
"""Process the file"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_file_extension(self, file_path: str) -> str:
|
|
||||||
"""Get file extension from path"""
|
|
||||||
return Path(file_path).suffix.lower()
|
|
||||||
|
|
||||||
def get_base_name(self, file_path: str) -> str:
|
|
||||||
"""Get base name without extension"""
|
|
||||||
return Path(file_path).stem
|
|
||||||
|
|
||||||
def validate_file(self, file_path: str) -> None:
|
|
||||||
"""Validate file exists and is accessible"""
|
|
||||||
path = Path(file_path)
|
|
||||||
if not path.exists():
|
|
||||||
raise FileProcessingError(f"File not found: {file_path}")
|
|
||||||
if not path.is_file():
|
|
||||||
raise FileProcessingError(f"Path is not a file: {file_path}")
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
"""
|
|
||||||
PDF file processor with OCR
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
from core import FileProcessingError
|
|
||||||
from config import settings
|
|
||||||
from services import vram_manager
|
|
||||||
from services.gpu_detector import gpu_detector
|
|
||||||
from .base_processor import FileProcessor
|
|
||||||
|
|
||||||
try:
|
|
||||||
import torch
|
|
||||||
import pytesseract
|
|
||||||
import easyocr
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from pdf2image import convert_from_path
|
|
||||||
from PIL import Image
|
|
||||||
PDF_OCR_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
PDF_OCR_AVAILABLE = False
|
|
||||||
# Provide stub for type hints
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
except ImportError:
|
|
||||||
Image = None # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
class PDFProcessor(FileProcessor):
|
|
||||||
"""Processor for PDF files with OCR"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__("PDFProcessor")
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._easyocr_reader = None
|
|
||||||
|
|
||||||
def can_process(self, file_path: str) -> bool:
|
|
||||||
"""Check if file is a PDF"""
|
|
||||||
return self.get_file_extension(file_path) == ".pdf"
|
|
||||||
|
|
||||||
def _load_easyocr(self):
|
|
||||||
"""Load EasyOCR reader"""
|
|
||||||
if self._easyocr_reader is None:
|
|
||||||
use_gpu = gpu_detector.is_available()
|
|
||||||
self.logger.info(f"Loading EasyOCR reader (GPU: {use_gpu})")
|
|
||||||
self._easyocr_reader = easyocr.Reader(['es'], gpu=use_gpu)
|
|
||||||
vram_manager.update_usage()
|
|
||||||
|
|
||||||
def _preprocess_image(self, image: Image.Image) -> Image.Image:
|
|
||||||
"""Preprocess image for better OCR"""
|
|
||||||
# Convert to grayscale
|
|
||||||
if image.mode != 'L':
|
|
||||||
image = image.convert('L')
|
|
||||||
|
|
||||||
# Simple preprocessing
|
|
||||||
image = image.resize((image.width * 2, image.height * 2), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
return image
|
|
||||||
|
|
||||||
def _run_ocr_parallel(self, pil_images) -> Dict[str, list]:
|
|
||||||
"""Run all OCR engines in parallel"""
|
|
||||||
results = {
|
|
||||||
'easyocr': [''] * len(pil_images),
|
|
||||||
'tesseract': [''] * len(pil_images)
|
|
||||||
}
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
||||||
futures = {}
|
|
||||||
|
|
||||||
# EasyOCR
|
|
||||||
if self._easyocr_reader:
|
|
||||||
futures['easyocr'] = executor.submit(
|
|
||||||
self._easyocr_reader.readtext_batched,
|
|
||||||
pil_images,
|
|
||||||
detail=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Tesseract
|
|
||||||
futures['tesseract'] = executor.submit(
|
|
||||||
lambda imgs: [pytesseract.image_to_string(img, lang='spa') for img in imgs],
|
|
||||||
pil_images
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collect results
|
|
||||||
for name, future in futures.items():
|
|
||||||
try:
|
|
||||||
results[name] = future.result()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"OCR engine {name} failed: {e}")
|
|
||||||
results[name] = [''] * len(pil_images)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def process(self, file_path: str) -> Dict[str, Any]:
|
|
||||||
"""Process PDF with OCR"""
|
|
||||||
self.validate_file(file_path)
|
|
||||||
pdf_path = Path(file_path)
|
|
||||||
output_path = settings.LOCAL_DOWNLOADS_PATH / f"{pdf_path.stem}.txt"
|
|
||||||
|
|
||||||
if not PDF_OCR_AVAILABLE:
|
|
||||||
raise FileProcessingError("PDF OCR dependencies not installed")
|
|
||||||
|
|
||||||
self.logger.info(f"Processing PDF file: {pdf_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Load EasyOCR if needed
|
|
||||||
self._load_easyocr()
|
|
||||||
vram_manager.update_usage()
|
|
||||||
|
|
||||||
# Convert PDF to images
|
|
||||||
self.logger.debug("Converting PDF to images")
|
|
||||||
pil_images = convert_from_path(
|
|
||||||
str(pdf_path),
|
|
||||||
dpi=settings.PDF_DPI,
|
|
||||||
fmt='png',
|
|
||||||
thread_count=settings.PDF_RENDER_THREAD_COUNT
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process in batches
|
|
||||||
all_text = []
|
|
||||||
batch_size = settings.PDF_BATCH_SIZE
|
|
||||||
|
|
||||||
for i in range(0, len(pil_images), batch_size):
|
|
||||||
batch = pil_images[i:i + batch_size]
|
|
||||||
self.logger.debug(f"Processing batch {i//batch_size + 1}/{(len(pil_images) + batch_size - 1)//batch_size}")
|
|
||||||
|
|
||||||
# Preprocess images
|
|
||||||
preprocessed_batch = [self._preprocess_image(img) for img in batch]
|
|
||||||
|
|
||||||
# Run OCR in parallel
|
|
||||||
ocr_results = self._run_ocr_parallel(preprocessed_batch)
|
|
||||||
|
|
||||||
# Combine results
|
|
||||||
for j, img in enumerate(batch):
|
|
||||||
# Take best result (simple approach: try EasyOCR first, then Tesseract)
|
|
||||||
text = ocr_results['easyocr'][j] if ocr_results['easyocr'][j] else ocr_results['tesseract'][j]
|
|
||||||
if text:
|
|
||||||
all_text.append(text)
|
|
||||||
|
|
||||||
# Save combined text
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("\n\n".join(all_text))
|
|
||||||
|
|
||||||
self.logger.info(f"PDF processing completed: {output_path}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"text_path": str(output_path),
|
|
||||||
"text": "\n\n".join(all_text),
|
|
||||||
"pages_processed": len(pil_images)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"PDF processing failed: {e}")
|
|
||||||
raise FileProcessingError(f"PDF processing failed: {e}")
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""Cleanup OCR models"""
|
|
||||||
self._easyocr_reader = None
|
|
||||||
vram_manager.cleanup()
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""
|
|
||||||
Text file processor
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any
|
|
||||||
from core import FileProcessingError
|
|
||||||
from config import settings
|
|
||||||
from .base_processor import FileProcessor
|
|
||||||
|
|
||||||
|
|
||||||
class TextProcessor(FileProcessor):
|
|
||||||
"""Processor for text files"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__("TextProcessor")
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def can_process(self, file_path: str) -> bool:
|
|
||||||
"""Check if file is a text file"""
|
|
||||||
ext = self.get_file_extension(file_path)
|
|
||||||
return ext in settings.TXT_EXTENSIONS
|
|
||||||
|
|
||||||
def process(self, file_path: str) -> Dict[str, Any]:
|
|
||||||
"""Process text file (copy to downloads)"""
|
|
||||||
self.validate_file(file_path)
|
|
||||||
text_path = Path(file_path)
|
|
||||||
output_path = settings.LOCAL_DOWNLOADS_PATH / text_path.name
|
|
||||||
|
|
||||||
self.logger.info(f"Processing text file: {text_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Copy file to downloads directory
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(text_path, 'r', encoding='utf-8') as src:
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as dst:
|
|
||||||
dst.write(src.read())
|
|
||||||
|
|
||||||
self.logger.info(f"Text file processing completed: {output_path}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"text_path": str(output_path),
|
|
||||||
"text": self._read_file(output_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Text processing failed: {e}")
|
|
||||||
raise FileProcessingError(f"Text processing failed: {e}")
|
|
||||||
|
|
||||||
def _read_file(self, file_path: Path) -> str:
|
|
||||||
"""Read file content"""
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
return f.read()
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Development dependencies
|
|
||||||
pytest>=7.4.0
|
|
||||||
pytest-cov>=4.1.0
|
|
||||||
pytest-mock>=3.11.0
|
|
||||||
pytest-asyncio>=0.21.0
|
|
||||||
coverage>=7.3.0
|
|
||||||
|
|
||||||
# Code quality
|
|
||||||
black>=23.0.0
|
|
||||||
flake8>=6.0.0
|
|
||||||
mypy>=1.5.0
|
|
||||||
isort>=5.12.0
|
|
||||||
|
|
||||||
# Security
|
|
||||||
bandit>=1.7.5
|
|
||||||
safety>=2.3.0
|
|
||||||
|
|
||||||
# Performance testing
|
|
||||||
pytest-benchmark>=4.0.0
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
mkdocs>=1.5.0
|
|
||||||
mkdocs-material>=9.0.0
|
|
||||||
41
requirements.txt
Executable file → Normal file
41
requirements.txt
Executable file → Normal file
@@ -1,31 +1,14 @@
|
|||||||
# Core web framework
|
# CBCFacil - Dependencias del proyecto
|
||||||
Flask>=3.0.0
|
anthropic>=0.18.0
|
||||||
Flask-CORS>=4.0.0
|
flask>=3.0.0
|
||||||
|
flask-cors>=4.0.0
|
||||||
# AI/ML dependencies
|
httpx>=0.27.0
|
||||||
torch>=2.0.0
|
markdown>=3.5.0
|
||||||
torchvision>=0.15.0
|
|
||||||
openai-whisper>=20231117
|
openai-whisper>=20231117
|
||||||
transformers>=4.30.0
|
pydub>=0.25.1
|
||||||
easyocr>=1.7.0
|
|
||||||
|
|
||||||
# Image processing
|
|
||||||
Pillow>=10.0.0
|
|
||||||
opencv-python-headless>=4.8.0
|
|
||||||
|
|
||||||
# Document processing
|
|
||||||
pdf2image>=1.17.0
|
|
||||||
pypdf>=3.17.0
|
|
||||||
python-docx>=0.8.11
|
|
||||||
reportlab>=4.0.0
|
|
||||||
pytesseract>=0.3.10
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
numpy>=1.24.0
|
|
||||||
requests>=2.31.0
|
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
webdavclient3>=0.9.8
|
python-json-logger>=2.0.0
|
||||||
|
reportlab>=4.0.0
|
||||||
# Optional: for enhanced functionality
|
torch[cuda]>=2.0.0
|
||||||
# unidecode>=1.3.7 # For filename normalization
|
watchdog>=4.0.0
|
||||||
# python-magic>=0.4.27 # For file type detection
|
webdavclient3>=0.4.2
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
"""
|
"""Export de servicios."""
|
||||||
Services package for CBCFacil
|
from .webdav_service import WebDAVService
|
||||||
"""
|
|
||||||
from .webdav_service import WebDAVService, webdav_service
|
|
||||||
from .vram_manager import VRAMManager, vram_manager
|
|
||||||
from .telegram_service import TelegramService, telegram_service
|
|
||||||
from .gpu_detector import GPUDetector, GPUType, gpu_detector
|
|
||||||
from .ai import ai_service
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'WebDAVService', 'webdav_service',
|
|
||||||
'VRAMManager', 'vram_manager',
|
|
||||||
'TelegramService', 'telegram_service',
|
|
||||||
'GPUDetector', 'GPUType', 'gpu_detector',
|
|
||||||
'ai_service'
|
|
||||||
]
|
|
||||||
|
|
||||||
|
__all__ = ["WebDAVService"]
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
"""
|
|
||||||
AI Providers package for CBCFacil
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .base_provider import AIProvider
|
|
||||||
from .claude_provider import ClaudeProvider
|
|
||||||
from .gemini_provider import GeminiProvider
|
|
||||||
from .provider_factory import AIProviderFactory, ai_provider_factory
|
|
||||||
|
|
||||||
# Alias for backwards compatibility
|
|
||||||
ai_service = ai_provider_factory
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'AIProvider',
|
|
||||||
'ClaudeProvider',
|
|
||||||
'GeminiProvider',
|
|
||||||
'AIProviderFactory',
|
|
||||||
'ai_provider_factory',
|
|
||||||
'ai_service'
|
|
||||||
]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""
|
|
||||||
Base AI Provider interface (Strategy pattern)
|
|
||||||
"""
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
|
|
||||||
|
|
||||||
class AIProvider(ABC):
|
|
||||||
"""Abstract base class for AI providers"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def summarize(self, text: str, **kwargs) -> str:
|
|
||||||
"""Generate summary of text"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def correct_text(self, text: str, **kwargs) -> str:
|
|
||||||
"""Correct grammar and spelling in text"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Classify content into categories"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def generate_text(self, prompt: str, **kwargs) -> str:
|
|
||||||
"""Generate text from prompt"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
"""Check if provider is available and configured"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def name(self) -> str:
|
|
||||||
"""Provider name"""
|
|
||||||
pass
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""
|
|
||||||
Claude AI Provider implementation
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
import shutil
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from core import AIProcessingError
|
|
||||||
from .base_provider import AIProvider
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeProvider(AIProvider):
|
|
||||||
"""Claude AI provider using CLI"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._cli_path = settings.CLAUDE_CLI_PATH or shutil.which("claude")
|
|
||||||
self._token = settings.ZAI_AUTH_TOKEN
|
|
||||||
self._base_url = settings.ZAI_BASE_URL
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "Claude"
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
"""Check if Claude CLI is available"""
|
|
||||||
return bool(self._cli_path and self._token)
|
|
||||||
|
|
||||||
def _get_env(self) -> Dict[str, str]:
|
|
||||||
"""Get environment variables for Claude"""
|
|
||||||
env = {
|
|
||||||
'ANTHROPIC_AUTH_TOKEN': self._token,
|
|
||||||
'ANTHROPIC_BASE_URL': self._base_url,
|
|
||||||
'PYTHONUNBUFFERED': '1'
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
|
|
||||||
def _run_cli(self, prompt: str, timeout: int = 300) -> str:
|
|
||||||
"""Run Claude CLI with prompt"""
|
|
||||||
if not self.is_available():
|
|
||||||
raise AIProcessingError("Claude CLI not available or not configured")
|
|
||||||
|
|
||||||
try:
|
|
||||||
cmd = [self._cli_path]
|
|
||||||
process = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
input=prompt,
|
|
||||||
env=self._get_env(),
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=timeout,
|
|
||||||
shell=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if process.returncode != 0:
|
|
||||||
error_msg = process.stderr or "Unknown error"
|
|
||||||
raise AIProcessingError(f"Claude CLI failed: {error_msg}")
|
|
||||||
|
|
||||||
return process.stdout.strip()
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise AIProcessingError(f"Claude CLI timed out after {timeout}s")
|
|
||||||
except Exception as e:
|
|
||||||
raise AIProcessingError(f"Claude CLI error: {e}")
|
|
||||||
|
|
||||||
def summarize(self, text: str, **kwargs) -> str:
|
|
||||||
"""Generate summary using Claude"""
|
|
||||||
prompt = f"""Summarize the following text:
|
|
||||||
|
|
||||||
{text}
|
|
||||||
|
|
||||||
Provide a clear, concise summary in Spanish."""
|
|
||||||
return self._run_cli(prompt)
|
|
||||||
|
|
||||||
def correct_text(self, text: str, **kwargs) -> str:
|
|
||||||
"""Correct text using Claude"""
|
|
||||||
prompt = f"""Correct the following text for grammar, spelling, and clarity:
|
|
||||||
|
|
||||||
{text}
|
|
||||||
|
|
||||||
Return only the corrected text, nothing else."""
|
|
||||||
return self._run_cli(prompt)
|
|
||||||
|
|
||||||
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Classify content using Claude"""
|
|
||||||
categories = ["historia", "analisis_contable", "instituciones_gobierno", "otras_clases"]
|
|
||||||
|
|
||||||
prompt = f"""Classify the following text into one of these categories:
|
|
||||||
- historia
|
|
||||||
- analisis_contable
|
|
||||||
- instituciones_gobierno
|
|
||||||
- otras_clases
|
|
||||||
|
|
||||||
Text: {text}
|
|
||||||
|
|
||||||
Return only the category name, nothing else."""
|
|
||||||
result = self._run_cli(prompt).lower()
|
|
||||||
|
|
||||||
# Validate result
|
|
||||||
if result not in categories:
|
|
||||||
result = "otras_clases"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"category": result,
|
|
||||||
"confidence": 0.9,
|
|
||||||
"provider": self.name
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_text(self, prompt: str, **kwargs) -> str:
|
|
||||||
"""Generate text using Claude"""
|
|
||||||
return self._run_cli(prompt)
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
"""
|
|
||||||
Gemini AI Provider - Optimized version with rate limiting and retry
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
import shutil
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from core import AIProcessingError
|
|
||||||
from .base_provider import AIProvider
|
|
||||||
|
|
||||||
|
|
||||||
class TokenBucket:
|
|
||||||
"""Token bucket rate limiter"""
|
|
||||||
|
|
||||||
def __init__(self, rate: float = 10, capacity: int = 20):
|
|
||||||
self.rate = rate # tokens per second
|
|
||||||
self.capacity = capacity
|
|
||||||
self.tokens = capacity
|
|
||||||
self.last_update = time.time()
|
|
||||||
self._lock = None # Lazy initialization
|
|
||||||
|
|
||||||
def _get_lock(self):
|
|
||||||
if self._lock is None:
|
|
||||||
import threading
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
return self._lock
|
|
||||||
|
|
||||||
def acquire(self, tokens: int = 1) -> float:
|
|
||||||
with self._get_lock():
|
|
||||||
now = time.time()
|
|
||||||
elapsed = now - self.last_update
|
|
||||||
self.last_update = now
|
|
||||||
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
|
|
||||||
|
|
||||||
if self.tokens >= tokens:
|
|
||||||
self.tokens -= tokens
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
wait_time = (tokens - self.tokens) / self.rate
|
|
||||||
self.tokens = 0
|
|
||||||
return wait_time
|
|
||||||
|
|
||||||
|
|
||||||
class CircuitBreaker:
|
|
||||||
"""Circuit breaker for API calls"""
|
|
||||||
|
|
||||||
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
|
|
||||||
self.failure_threshold = failure_threshold
|
|
||||||
self.recovery_timeout = recovery_timeout
|
|
||||||
self.failures = 0
|
|
||||||
self.last_failure: Optional[datetime] = None
|
|
||||||
self.state = "closed" # closed, open, half-open
|
|
||||||
self._lock = None
|
|
||||||
|
|
||||||
def _get_lock(self):
|
|
||||||
if self._lock is None:
|
|
||||||
import threading
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
return self._lock
|
|
||||||
|
|
||||||
def call(self, func, *args, **kwargs):
|
|
||||||
with self._get_lock():
|
|
||||||
if self.state == "open":
|
|
||||||
if self.last_failure and (datetime.utcnow() - self.last_failure).total_seconds() > self.recovery_timeout:
|
|
||||||
self.state = "half-open"
|
|
||||||
else:
|
|
||||||
raise AIProcessingError("Circuit breaker is open")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
if self.state == "half-open":
|
|
||||||
self.state = "closed"
|
|
||||||
self.failures = 0
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
self.failures += 1
|
|
||||||
self.last_failure = datetime.utcnow()
|
|
||||||
if self.failures >= self.failure_threshold:
|
|
||||||
self.state = "open"
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiProvider(AIProvider):
|
|
||||||
"""Gemini AI provider with rate limiting and retry"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._cli_path = settings.GEMINI_CLI_PATH or shutil.which("gemini")
|
|
||||||
self._api_key = settings.GEMINI_API_KEY
|
|
||||||
self._flash_model = settings.GEMINI_FLASH_MODEL
|
|
||||||
self._pro_model = settings.GEMINI_PRO_MODEL
|
|
||||||
self._session = None
|
|
||||||
self._rate_limiter = TokenBucket(rate=15, capacity=30)
|
|
||||||
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
|
|
||||||
self._retry_config = {
|
|
||||||
"max_attempts": 3,
|
|
||||||
"base_delay": 1.0,
|
|
||||||
"max_delay": 30.0,
|
|
||||||
"exponential_base": 2
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "Gemini"
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
"""Check if Gemini CLI or API is available"""
|
|
||||||
return bool(self._cli_path or self._api_key)
|
|
||||||
|
|
||||||
def _init_session(self) -> None:
|
|
||||||
"""Initialize HTTP session with connection pooling"""
|
|
||||||
if self._session is None:
|
|
||||||
self._session = requests.Session()
|
|
||||||
adapter = requests.adapters.HTTPAdapter(
|
|
||||||
pool_connections=10,
|
|
||||||
pool_maxsize=20,
|
|
||||||
max_retries=0 # We handle retries manually
|
|
||||||
)
|
|
||||||
self._session.mount('https://', adapter)
|
|
||||||
|
|
||||||
def _run_with_retry(self, func, *args, **kwargs):
|
|
||||||
"""Execute function with exponential backoff retry"""
|
|
||||||
max_attempts = self._retry_config["max_attempts"]
|
|
||||||
base_delay = self._retry_config["base_delay"]
|
|
||||||
|
|
||||||
last_exception = None
|
|
||||||
|
|
||||||
for attempt in range(max_attempts):
|
|
||||||
try:
|
|
||||||
return self._circuit_breaker.call(func, *args, **kwargs)
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
last_exception = e
|
|
||||||
if attempt < max_attempts - 1:
|
|
||||||
delay = min(
|
|
||||||
base_delay * (2 ** attempt),
|
|
||||||
self._retry_config["max_delay"]
|
|
||||||
)
|
|
||||||
# Add jitter
|
|
||||||
delay += delay * 0.1 * (time.time() % 1)
|
|
||||||
self.logger.warning(f"Attempt {attempt + 1} failed: {e}, retrying in {delay:.2f}s")
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
raise AIProcessingError(f"Max retries exceeded: {last_exception}")
|
|
||||||
|
|
||||||
def _run_cli(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str:
|
|
||||||
"""Run Gemini CLI with prompt"""
|
|
||||||
if not self._cli_path:
|
|
||||||
raise AIProcessingError("Gemini CLI not available")
|
|
||||||
|
|
||||||
model = self._flash_model if use_flash else self._pro_model
|
|
||||||
cmd = [self._cli_path, model, prompt]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Apply rate limiting
|
|
||||||
wait_time = self._rate_limiter.acquire()
|
|
||||||
if wait_time > 0:
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
process = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=timeout,
|
|
||||||
shell=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if process.returncode != 0:
|
|
||||||
error_msg = process.stderr or "Unknown error"
|
|
||||||
raise AIProcessingError(f"Gemini CLI failed: {error_msg}")
|
|
||||||
|
|
||||||
return process.stdout.strip()
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise AIProcessingError(f"Gemini CLI timed out after {timeout}s")
|
|
||||||
except Exception as e:
|
|
||||||
raise AIProcessingError(f"Gemini CLI error: {e}")
|
|
||||||
|
|
||||||
def _call_api(self, prompt: str, use_flash: bool = True, timeout: int = 180) -> str:
|
|
||||||
"""Call Gemini API with rate limiting and retry"""
|
|
||||||
if not self._api_key:
|
|
||||||
raise AIProcessingError("Gemini API key not configured")
|
|
||||||
|
|
||||||
self._init_session()
|
|
||||||
|
|
||||||
model = self._flash_model if use_flash else self._pro_model
|
|
||||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"contents": [{
|
|
||||||
"parts": [{"text": prompt}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {"key": self._api_key}
|
|
||||||
|
|
||||||
def api_call():
|
|
||||||
# Apply rate limiting
|
|
||||||
wait_time = self._rate_limiter.acquire()
|
|
||||||
if wait_time > 0:
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
response = self._session.post(
|
|
||||||
url,
|
|
||||||
json=payload,
|
|
||||||
params=params,
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response
|
|
||||||
|
|
||||||
response = self._run_with_retry(api_call)
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if "candidates" not in data or not data["candidates"]:
|
|
||||||
raise AIProcessingError("Empty response from Gemini API")
|
|
||||||
|
|
||||||
candidate = data["candidates"][0]
|
|
||||||
if "content" not in candidate or "parts" not in candidate["content"]:
|
|
||||||
raise AIProcessingError("Invalid response format from Gemini API")
|
|
||||||
|
|
||||||
result = candidate["content"]["parts"][0]["text"]
|
|
||||||
return result.strip()
|
|
||||||
|
|
||||||
def _run(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str:
|
|
||||||
"""Run Gemini with fallback between CLI and API"""
|
|
||||||
# Try CLI first if available
|
|
||||||
if self._cli_path:
|
|
||||||
try:
|
|
||||||
return self._run_cli(prompt, use_flash, timeout)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Gemini CLI failed, trying API: {e}")
|
|
||||||
|
|
||||||
# Fallback to API
|
|
||||||
if self._api_key:
|
|
||||||
api_timeout = min(timeout, 180)
|
|
||||||
return self._call_api(prompt, use_flash, api_timeout)
|
|
||||||
|
|
||||||
raise AIProcessingError("No Gemini provider available (CLI or API)")
|
|
||||||
|
|
||||||
def summarize(self, text: str, **kwargs) -> str:
|
|
||||||
"""Generate summary using Gemini"""
|
|
||||||
prompt = f"""Summarize the following text:
|
|
||||||
|
|
||||||
{text}
|
|
||||||
|
|
||||||
Provide a clear, concise summary in Spanish."""
|
|
||||||
return self._run(prompt, use_flash=True)
|
|
||||||
|
|
||||||
def correct_text(self, text: str, **kwargs) -> str:
|
|
||||||
"""Correct text using Gemini"""
|
|
||||||
prompt = f"""Correct the following text for grammar, spelling, and clarity:
|
|
||||||
|
|
||||||
{text}
|
|
||||||
|
|
||||||
Return only the corrected text, nothing else."""
|
|
||||||
return self._run(prompt, use_flash=True)
|
|
||||||
|
|
||||||
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Classify content using Gemini"""
|
|
||||||
categories = ["historia", "analisis_contable", "instituciones_gobierno", "otras_clases"]
|
|
||||||
|
|
||||||
prompt = f"""Classify the following text into one of these categories:
|
|
||||||
- historia
|
|
||||||
- analisis_contable
|
|
||||||
- instituciones_gobierno
|
|
||||||
- otras_clases
|
|
||||||
|
|
||||||
Text: {text}
|
|
||||||
|
|
||||||
Return only the category name, nothing else."""
|
|
||||||
result = self._run(prompt, use_flash=True).lower()
|
|
||||||
|
|
||||||
# Validate result
|
|
||||||
if result not in categories:
|
|
||||||
result = "otras_clases"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"category": result,
|
|
||||||
"confidence": 0.9,
|
|
||||||
"provider": self.name
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_text(self, prompt: str, **kwargs) -> str:
|
|
||||||
"""Generate text using Gemini"""
|
|
||||||
use_flash = kwargs.get('use_flash', True)
|
|
||||||
if self._api_key:
|
|
||||||
return self._call_api(prompt, use_flash=use_flash)
|
|
||||||
return self._call_cli(prompt, use_yolo=True)
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
|
||||||
"""Get provider statistics"""
|
|
||||||
return {
|
|
||||||
"rate_limiter": {
|
|
||||||
"tokens": round(self._rate_limiter.tokens, 2),
|
|
||||||
"capacity": self._rate_limiter.capacity,
|
|
||||||
"rate": self._rate_limiter.rate
|
|
||||||
},
|
|
||||||
"circuit_breaker": {
|
|
||||||
"state": self._circuit_breaker.state,
|
|
||||||
"failures": self._circuit_breaker.failures,
|
|
||||||
"failure_threshold": self._circuit_breaker.failure_threshold
|
|
||||||
},
|
|
||||||
"cli_available": bool(self._cli_path),
|
|
||||||
"api_available": bool(self._api_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance is created in __init__.py
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""
|
|
||||||
AI Provider Factory (Factory Pattern)
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Type
|
|
||||||
|
|
||||||
from core import AIProcessingError
|
|
||||||
from .base_provider import AIProvider
|
|
||||||
from .claude_provider import ClaudeProvider
|
|
||||||
from .gemini_provider import GeminiProvider
|
|
||||||
|
|
||||||
|
|
||||||
class AIProviderFactory:
|
|
||||||
"""Factory for creating AI providers with fallback"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._providers: Dict[str, AIProvider] = {
|
|
||||||
'claude': ClaudeProvider(),
|
|
||||||
'gemini': GeminiProvider()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_provider(self, preferred: str = 'gemini') -> AIProvider:
|
|
||||||
"""Get available provider with fallback"""
|
|
||||||
# Try preferred provider first
|
|
||||||
if preferred in self._providers:
|
|
||||||
provider = self._providers[preferred]
|
|
||||||
if provider.is_available():
|
|
||||||
self.logger.info(f"Using {preferred} provider")
|
|
||||||
return provider
|
|
||||||
|
|
||||||
# Fallback to any available provider
|
|
||||||
for name, provider in self._providers.items():
|
|
||||||
if provider.is_available():
|
|
||||||
self.logger.info(f"Falling back to {name} provider")
|
|
||||||
return provider
|
|
||||||
|
|
||||||
raise AIProcessingError("No AI providers available")
|
|
||||||
|
|
||||||
def get_all_available(self) -> Dict[str, AIProvider]:
|
|
||||||
"""Get all available providers"""
|
|
||||||
return {
|
|
||||||
name: provider
|
|
||||||
for name, provider in self._providers.items()
|
|
||||||
if provider.is_available()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_best_provider(self) -> AIProvider:
|
|
||||||
"""Get the best available provider (Gemini > Claude)"""
|
|
||||||
return self.get_provider('gemini')
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
ai_provider_factory = AIProviderFactory()
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
"""
|
|
||||||
AI Service - Unified interface for AI providers with caching
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from core import AIProcessingError
|
|
||||||
from .ai.provider_factory import AIProviderFactory, ai_provider_factory
|
|
||||||
|
|
||||||
|
|
||||||
class LRUCache:
|
|
||||||
"""Thread-safe LRU Cache implementation"""
|
|
||||||
|
|
||||||
def __init__(self, max_size: int = 100, ttl: int = 3600):
|
|
||||||
self.max_size = max_size
|
|
||||||
self.ttl = ttl
|
|
||||||
self._cache: Dict[str, tuple[str, float]] = {}
|
|
||||||
self._order: list[str] = []
|
|
||||||
self._lock = Lock()
|
|
||||||
|
|
||||||
def _is_expired(self, timestamp: float) -> bool:
|
|
||||||
return (time.time() - timestamp) > self.ttl
|
|
||||||
|
|
||||||
def get(self, key: str) -> Optional[str]:
|
|
||||||
with self._lock:
|
|
||||||
if key not in self._cache:
|
|
||||||
return None
|
|
||||||
value, timestamp = self._cache[key]
|
|
||||||
if self._is_expired(timestamp):
|
|
||||||
del self._cache[key]
|
|
||||||
self._order.remove(key)
|
|
||||||
return None
|
|
||||||
# Move to end (most recently used)
|
|
||||||
self._order.remove(key)
|
|
||||||
self._order.append(key)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def set(self, key: str, value: str) -> None:
|
|
||||||
with self._lock:
|
|
||||||
if key in self._cache:
|
|
||||||
self._order.remove(key)
|
|
||||||
elif len(self._order) >= self.max_size:
|
|
||||||
# Remove least recently used
|
|
||||||
oldest = self._order.pop(0)
|
|
||||||
del self._cache[oldest]
|
|
||||||
self._cache[key] = (value, time.time())
|
|
||||||
self._order.append(key)
|
|
||||||
|
|
||||||
def stats(self) -> Dict[str, int]:
|
|
||||||
with self._lock:
|
|
||||||
return {
|
|
||||||
"size": len(self._cache),
|
|
||||||
"max_size": self.max_size,
|
|
||||||
"hits": sum(1 for _, t in self._cache.values() if not self._is_expired(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
|
||||||
"""Token bucket rate limiter"""
|
|
||||||
|
|
||||||
def __init__(self, rate: float = 10, capacity: int = 20):
|
|
||||||
self.rate = rate # tokens per second
|
|
||||||
self.capacity = capacity
|
|
||||||
self.tokens = capacity
|
|
||||||
self.last_update = time.time()
|
|
||||||
self._lock = Lock()
|
|
||||||
|
|
||||||
def acquire(self, tokens: int = 1) -> float:
|
|
||||||
with self._lock:
|
|
||||||
now = time.time()
|
|
||||||
elapsed = now - self.last_update
|
|
||||||
self.last_update = now
|
|
||||||
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
|
|
||||||
|
|
||||||
if self.tokens >= tokens:
|
|
||||||
self.tokens -= tokens
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
wait_time = (tokens - self.tokens) / self.rate
|
|
||||||
self.tokens = 0
|
|
||||||
return wait_time
|
|
||||||
|
|
||||||
|
|
||||||
class AIService:
|
|
||||||
"""Unified service for AI operations with caching and rate limiting"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._factory: Optional[AIProviderFactory] = None
|
|
||||||
self._prompt_cache = LRUCache(max_size=100, ttl=3600) # 1 hour TTL
|
|
||||||
self._rate_limiter = RateLimiter(rate=15, capacity=30)
|
|
||||||
self._stats = {
|
|
||||||
"total_requests": 0,
|
|
||||||
"cache_hits": 0,
|
|
||||||
"api_calls": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def factory(self) -> AIProviderFactory:
|
|
||||||
"""Lazy initialization of provider factory"""
|
|
||||||
if self._factory is None:
|
|
||||||
self._factory = ai_provider_factory
|
|
||||||
return self._factory
|
|
||||||
|
|
||||||
def _get_cache_key(self, prompt: str, operation: str) -> str:
|
|
||||||
"""Generate cache key from prompt and operation"""
|
|
||||||
content = f"{operation}:{prompt[:500]}" # Limit prompt length
|
|
||||||
return hashlib.sha256(content.encode()).hexdigest()
|
|
||||||
|
|
||||||
def generate_text(
|
|
||||||
self,
|
|
||||||
prompt: str,
|
|
||||||
provider: Optional[str] = None,
|
|
||||||
max_tokens: int = 4096
|
|
||||||
) -> str:
|
|
||||||
"""Generate text using AI provider with caching"""
|
|
||||||
self._stats["total_requests"] += 1
|
|
||||||
|
|
||||||
cache_key = self._get_cache_key(prompt, f"generate:{provider or 'default'}")
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
cached_result = self._prompt_cache.get(cache_key)
|
|
||||||
if cached_result:
|
|
||||||
self._stats["cache_hits"] += 1
|
|
||||||
self.logger.debug(f"Cache hit for generate_text ({len(cached_result)} chars)")
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
# Apply rate limiting
|
|
||||||
wait_time = self._rate_limiter.acquire()
|
|
||||||
if wait_time > 0:
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._stats["api_calls"] += 1
|
|
||||||
ai_provider = self.factory.get_provider(provider or 'gemini')
|
|
||||||
result = ai_provider.generate(prompt, max_tokens=max_tokens)
|
|
||||||
|
|
||||||
# Cache result
|
|
||||||
self._prompt_cache.set(cache_key, result)
|
|
||||||
|
|
||||||
return result
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"AI generation failed: {e}")
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
def summarize(self, text: str, **kwargs) -> str:
|
|
||||||
"""Generate summary of text with caching"""
|
|
||||||
self._stats["total_requests"] += 1
|
|
||||||
|
|
||||||
cache_key = self._get_cache_key(text, "summarize")
|
|
||||||
|
|
||||||
cached_result = self._prompt_cache.get(cache_key)
|
|
||||||
if cached_result:
|
|
||||||
self._stats["cache_hits"] += 1
|
|
||||||
self.logger.debug(f"Cache hit for summarize ({len(cached_result)} chars)")
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
wait_time = self._rate_limiter.acquire()
|
|
||||||
if wait_time > 0:
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._stats["api_calls"] += 1
|
|
||||||
provider = self.factory.get_best_provider()
|
|
||||||
result = provider.summarize(text, **kwargs)
|
|
||||||
|
|
||||||
self._prompt_cache.set(cache_key, result)
|
|
||||||
return result
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"Summarization failed: {e}")
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
def correct_text(self, text: str, **kwargs) -> str:
|
|
||||||
"""Correct grammar and spelling with caching"""
|
|
||||||
self._stats["total_requests"] += 1
|
|
||||||
|
|
||||||
cache_key = self._get_cache_key(text, "correct")
|
|
||||||
|
|
||||||
cached_result = self._prompt_cache.get(cache_key)
|
|
||||||
if cached_result:
|
|
||||||
self._stats["cache_hits"] += 1
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
wait_time = self._rate_limiter.acquire()
|
|
||||||
if wait_time > 0:
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._stats["api_calls"] += 1
|
|
||||||
provider = self.factory.get_best_provider()
|
|
||||||
result = provider.correct_text(text, **kwargs)
|
|
||||||
|
|
||||||
self._prompt_cache.set(cache_key, result)
|
|
||||||
return result
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"Text correction failed: {e}")
|
|
||||||
return text
|
|
||||||
|
|
||||||
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Classify content into categories with caching"""
|
|
||||||
self._stats["total_requests"] += 1
|
|
||||||
|
|
||||||
# For classification, use a shorter text for cache key
|
|
||||||
short_text = text[:200]
|
|
||||||
cache_key = self._get_cache_key(short_text, "classify")
|
|
||||||
|
|
||||||
cached_result = self._prompt_cache.get(cache_key)
|
|
||||||
if cached_result:
|
|
||||||
self._stats["cache_hits"] += 1
|
|
||||||
import json
|
|
||||||
return json.loads(cached_result)
|
|
||||||
|
|
||||||
wait_time = self._rate_limiter.acquire()
|
|
||||||
if wait_time > 0:
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._stats["api_calls"] += 1
|
|
||||||
provider = self.factory.get_best_provider()
|
|
||||||
result = provider.classify_content(text, **kwargs)
|
|
||||||
|
|
||||||
import json
|
|
||||||
self._prompt_cache.set(cache_key, json.dumps(result))
|
|
||||||
return result
|
|
||||||
except AIProcessingError as e:
|
|
||||||
self.logger.error(f"Classification failed: {e}")
|
|
||||||
return {"category": "otras_clases", "confidence": 0.0}
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
|
||||||
"""Get service statistics"""
|
|
||||||
cache_stats = self._prompt_cache.stats()
|
|
||||||
hit_rate = (self._stats["cache_hits"] / self._stats["total_requests"] * 100) if self._stats["total_requests"] > 0 else 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
**self._stats,
|
|
||||||
"cache_size": cache_stats["size"],
|
|
||||||
"cache_max_size": cache_stats["max_size"],
|
|
||||||
"cache_hit_rate": round(hit_rate, 2),
|
|
||||||
"rate_limiter": {
|
|
||||||
"tokens": self._rate_limiter.tokens,
|
|
||||||
"capacity": self._rate_limiter.capacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def clear_cache(self) -> None:
|
|
||||||
"""Clear the prompt cache"""
|
|
||||||
self._prompt_cache = LRUCache(max_size=100, ttl=3600)
|
|
||||||
self.logger.info("AI service cache cleared")
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
ai_service = AIService()
|
|
||||||
158
services/ai_summary_service.py
Normal file
158
services/ai_summary_service.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""AI Summary Service using Anthropic/Z.AI API (GLM)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AISummaryService:
|
||||||
|
"""Service for AI-powered text summarization using Anthropic/Z.AI API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
auth_token: Optional[str] = None,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
timeout: int = 120,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the AI Summary Service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_token: API authentication token. Defaults to ANTHROPIC_AUTH_TOKEN env var.
|
||||||
|
base_url: API base URL. Defaults to ANTHROPIC_BASE_URL env var.
|
||||||
|
model: Model identifier. Defaults to ANTHROPIC_MODEL env var.
|
||||||
|
timeout: Request timeout in seconds. Defaults to 120.
|
||||||
|
"""
|
||||||
|
self.auth_token = auth_token or os.getenv("ANTHROPIC_AUTH_TOKEN")
|
||||||
|
# Normalize base_url: remove /anthropic suffix if present
|
||||||
|
raw_base_url = base_url or os.getenv("ANTHROPIC_BASE_URL")
|
||||||
|
if raw_base_url and raw_base_url.endswith("/anthropic"):
|
||||||
|
raw_base_url = raw_base_url[:-len("/anthropic")]
|
||||||
|
self.base_url = raw_base_url
|
||||||
|
self.model = model or os.getenv("ANTHROPIC_MODEL", "glm-4")
|
||||||
|
self.timeout = timeout
|
||||||
|
self._available = bool(self.auth_token and self.base_url)
|
||||||
|
|
||||||
|
if self._available:
|
||||||
|
logger.info(
|
||||||
|
"AISummaryService initialized with model=%s, base_url=%s",
|
||||||
|
self.model,
|
||||||
|
self.base_url,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("AISummaryService: no configuration found, running in silent mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if the service is properly configured."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def summarize(self, text: str, prompt_template: Optional[str] = None) -> str:
|
||||||
|
"""Summarize the given text using the AI API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to summarize.
|
||||||
|
prompt_template: Optional custom prompt template. If None, uses default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The summarized text.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the service is not configured.
|
||||||
|
requests.RequestException: If the API call fails.
|
||||||
|
"""
|
||||||
|
if not self._available:
|
||||||
|
logger.debug("AISummaryService not configured, returning original text")
|
||||||
|
return text
|
||||||
|
|
||||||
|
default_prompt = "Resume el siguiente texto de manera clara y concisa:"
|
||||||
|
prompt = prompt_template.format(text=text) if prompt_template else f"{default_prompt}\n\n{text}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"temperature": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.auth_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug("Calling AI API for summarization (text length: %d)", len(text))
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/v1/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
summary = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
logger.info("Summarization completed successfully (output length: %d)", len(summary))
|
||||||
|
return summary
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
logger.error("AI API request timed out after %d seconds", self.timeout)
|
||||||
|
raise requests.RequestException(f"Request timed out after {self.timeout}s") from None
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("AI API request failed: %s", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def fix_latex(self, text: str) -> str:
|
||||||
|
"""Fix LaTeX formatting issues in the given text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text containing LaTeX to fix.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The text with corrected LaTeX formatting.
|
||||||
|
"""
|
||||||
|
if not self._available:
|
||||||
|
logger.debug("AISummaryService not configured, returning original text")
|
||||||
|
return text
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"Corrige los errores de formato LaTeX en el siguiente texto. "
|
||||||
|
"Mantén el contenido pero corrige la sintaxis de LaTeX:\n\n"
|
||||||
|
f"{text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.auth_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug("Calling AI API for LaTeX fixing (text length: %d)", len(text))
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/v1/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
fixed = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
logger.info("LaTeX fixing completed successfully")
|
||||||
|
return fixed
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("LaTeX fixing failed: %s", str(e))
|
||||||
|
return text
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
"""
|
|
||||||
GPU Detection and Management Service
|
|
||||||
|
|
||||||
Provides unified interface for detecting and using NVIDIA (CUDA), AMD (ROCm), or CPU.
|
|
||||||
Fallback order: NVIDIA -> AMD -> CPU
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import shutil
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Try to import torch
|
|
||||||
try:
|
|
||||||
import torch
|
|
||||||
TORCH_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
TORCH_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
class GPUType(Enum):
|
|
||||||
"""Supported GPU types"""
|
|
||||||
NVIDIA = "nvidia"
|
|
||||||
AMD = "amd"
|
|
||||||
CPU = "cpu"
|
|
||||||
|
|
||||||
|
|
||||||
class GPUDetector:
|
|
||||||
"""
|
|
||||||
Service for detecting and managing GPU resources.
|
|
||||||
|
|
||||||
Detects GPU type with fallback order: NVIDIA -> AMD -> CPU
|
|
||||||
Provides unified interface regardless of GPU vendor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._gpu_type: Optional[GPUType] = None
|
|
||||||
self._device: Optional[str] = None
|
|
||||||
self._initialized: bool = False
|
|
||||||
|
|
||||||
def initialize(self) -> None:
|
|
||||||
"""Initialize GPU detection"""
|
|
||||||
if self._initialized:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._gpu_type = self._detect_gpu_type()
|
|
||||||
self._device = self._get_device_string()
|
|
||||||
self._setup_environment()
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
logger.info(f"GPU Detector initialized: {self._gpu_type.value} -> {self._device}")
|
|
||||||
|
|
||||||
def _detect_gpu_type(self) -> GPUType:
|
|
||||||
"""
|
|
||||||
Detect available GPU type.
|
|
||||||
Order: NVIDIA -> AMD -> CPU
|
|
||||||
"""
|
|
||||||
# Check user preference first
|
|
||||||
preference = os.getenv("GPU_PREFERENCE", "auto").lower()
|
|
||||||
if preference == "cpu":
|
|
||||||
logger.info("GPU preference set to CPU, skipping GPU detection")
|
|
||||||
return GPUType.CPU
|
|
||||||
|
|
||||||
if not TORCH_AVAILABLE:
|
|
||||||
logger.warning("PyTorch not available, using CPU")
|
|
||||||
return GPUType.CPU
|
|
||||||
|
|
||||||
# Check NVIDIA first
|
|
||||||
if preference in ("auto", "nvidia"):
|
|
||||||
if self._check_nvidia():
|
|
||||||
logger.info("NVIDIA GPU detected via nvidia-smi")
|
|
||||||
return GPUType.NVIDIA
|
|
||||||
|
|
||||||
# Check AMD second
|
|
||||||
if preference in ("auto", "amd"):
|
|
||||||
if self._check_amd():
|
|
||||||
logger.info("AMD GPU detected via ROCm")
|
|
||||||
return GPUType.AMD
|
|
||||||
|
|
||||||
# Fallback to checking torch.cuda (works for both NVIDIA and ROCm)
|
|
||||||
if torch.cuda.is_available():
|
|
||||||
device_name = torch.cuda.get_device_name(0).lower()
|
|
||||||
if "nvidia" in device_name or "geforce" in device_name or "rtx" in device_name or "gtx" in device_name:
|
|
||||||
return GPUType.NVIDIA
|
|
||||||
elif "amd" in device_name or "radeon" in device_name or "rx" in device_name:
|
|
||||||
return GPUType.AMD
|
|
||||||
else:
|
|
||||||
# Unknown GPU vendor but CUDA works
|
|
||||||
logger.warning(f"Unknown GPU vendor: {device_name}, treating as NVIDIA-compatible")
|
|
||||||
return GPUType.NVIDIA
|
|
||||||
|
|
||||||
logger.info("No GPU detected, using CPU")
|
|
||||||
return GPUType.CPU
|
|
||||||
|
|
||||||
def _check_nvidia(self) -> bool:
|
|
||||||
"""Check if NVIDIA GPU is available using nvidia-smi"""
|
|
||||||
nvidia_smi = shutil.which("nvidia-smi")
|
|
||||||
if not nvidia_smi:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[nvidia_smi, "--query-gpu=name", "--format=csv,noheader"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
return result.returncode == 0 and result.stdout.strip()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"nvidia-smi check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _check_amd(self) -> bool:
|
|
||||||
"""Check if AMD GPU is available using rocm-smi"""
|
|
||||||
rocm_smi = shutil.which("rocm-smi")
|
|
||||||
if not rocm_smi:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[rocm_smi, "--showproductname"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
return result.returncode == 0 and "GPU" in result.stdout
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"rocm-smi check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _setup_environment(self) -> None:
|
|
||||||
"""Set up environment variables for detected GPU"""
|
|
||||||
if self._gpu_type == GPUType.AMD:
|
|
||||||
# Set HSA override for AMD RX 6000 series (gfx1030)
|
|
||||||
hsa_version = os.getenv("HSA_OVERRIDE_GFX_VERSION", "10.3.0")
|
|
||||||
os.environ.setdefault("HSA_OVERRIDE_GFX_VERSION", hsa_version)
|
|
||||||
logger.info(f"Set HSA_OVERRIDE_GFX_VERSION={hsa_version}")
|
|
||||||
|
|
||||||
def _get_device_string(self) -> str:
|
|
||||||
"""Get PyTorch device string"""
|
|
||||||
if self._gpu_type in (GPUType.NVIDIA, GPUType.AMD):
|
|
||||||
return "cuda"
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def gpu_type(self) -> GPUType:
|
|
||||||
"""Get detected GPU type"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._gpu_type
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device(self) -> str:
|
|
||||||
"""Get device string for PyTorch"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._device
|
|
||||||
|
|
||||||
def get_device(self) -> "torch.device":
|
|
||||||
"""Get PyTorch device object"""
|
|
||||||
if not TORCH_AVAILABLE:
|
|
||||||
raise RuntimeError("PyTorch not available")
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return torch.device(self._device)
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
"""Check if GPU is available"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._gpu_type in (GPUType.NVIDIA, GPUType.AMD)
|
|
||||||
|
|
||||||
def is_nvidia(self) -> bool:
|
|
||||||
"""Check if NVIDIA GPU is being used"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._gpu_type == GPUType.NVIDIA
|
|
||||||
|
|
||||||
def is_amd(self) -> bool:
|
|
||||||
"""Check if AMD GPU is being used"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._gpu_type == GPUType.AMD
|
|
||||||
|
|
||||||
def is_cpu(self) -> bool:
|
|
||||||
"""Check if CPU is being used"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._gpu_type == GPUType.CPU
|
|
||||||
|
|
||||||
def get_device_name(self) -> str:
|
|
||||||
"""Get GPU device name"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
|
|
||||||
if self._gpu_type == GPUType.CPU:
|
|
||||||
return "CPU"
|
|
||||||
|
|
||||||
if TORCH_AVAILABLE and torch.cuda.is_available():
|
|
||||||
return torch.cuda.get_device_name(0)
|
|
||||||
|
|
||||||
return "Unknown"
|
|
||||||
|
|
||||||
def get_memory_info(self) -> Dict[str, Any]:
|
|
||||||
"""Get GPU memory information"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
|
|
||||||
if self._gpu_type == GPUType.CPU:
|
|
||||||
return {"type": "cpu", "error": "No GPU available"}
|
|
||||||
|
|
||||||
if not TORCH_AVAILABLE or not torch.cuda.is_available():
|
|
||||||
return {"type": self._gpu_type.value, "error": "CUDA not available"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
props = torch.cuda.get_device_properties(0)
|
|
||||||
total = props.total_memory / 1024**3
|
|
||||||
allocated = torch.cuda.memory_allocated(0) / 1024**3
|
|
||||||
reserved = torch.cuda.memory_reserved(0) / 1024**3
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": self._gpu_type.value,
|
|
||||||
"device_name": props.name,
|
|
||||||
"total_gb": round(total, 2),
|
|
||||||
"allocated_gb": round(allocated, 2),
|
|
||||||
"reserved_gb": round(reserved, 2),
|
|
||||||
"free_gb": round(total - allocated, 2),
|
|
||||||
"usage_percent": round((allocated / total) * 100, 1)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {"type": self._gpu_type.value, "error": str(e)}
|
|
||||||
|
|
||||||
def empty_cache(self) -> None:
|
|
||||||
"""Clear GPU memory cache"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
|
|
||||||
if TORCH_AVAILABLE and torch.cuda.is_available():
|
|
||||||
torch.cuda.empty_cache()
|
|
||||||
logger.debug("GPU cache cleared")
|
|
||||||
|
|
||||||
|
|
||||||
# Global singleton instance
|
|
||||||
gpu_detector = GPUDetector()
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""
|
|
||||||
Performance metrics collector for CBCFacil
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import psutil
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsCollector:
|
|
||||||
"""Collect and aggregate performance metrics"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._start_time = time.time()
|
|
||||||
self._request_count = 0
|
|
||||||
self._error_count = 0
|
|
||||||
self._total_latency = 0.0
|
|
||||||
self._latencies = []
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._process = psutil.Process()
|
|
||||||
|
|
||||||
def record_request(self, latency: float, success: bool = True) -> None:
|
|
||||||
"""Record a request with latency"""
|
|
||||||
with self._lock:
|
|
||||||
self._request_count += 1
|
|
||||||
self._total_latency += latency
|
|
||||||
self._latencies.append(latency)
|
|
||||||
|
|
||||||
# Keep only last 1000 latencies for memory efficiency
|
|
||||||
if len(self._latencies) > 1000:
|
|
||||||
self._latencies = self._latencies[-1000:]
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
self._error_count += 1
|
|
||||||
|
|
||||||
def get_latency_percentiles(self) -> Dict[str, float]:
|
|
||||||
"""Calculate latency percentiles"""
|
|
||||||
with self._lock:
|
|
||||||
if not self._latencies:
|
|
||||||
return {"p50": 0, "p95": 0, "p99": 0}
|
|
||||||
|
|
||||||
sorted_latencies = sorted(self._latencies)
|
|
||||||
n = len(sorted_latencies)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"p50": sorted_latencies[int(n * 0.50)],
|
|
||||||
"p95": sorted_latencies[int(n * 0.95)],
|
|
||||||
"p99": sorted_latencies[int(n * 0.99)]
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_system_metrics(self) -> Dict[str, Any]:
|
|
||||||
"""Get system resource metrics"""
|
|
||||||
try:
|
|
||||||
memory = self._process.memory_info()
|
|
||||||
cpu_percent = self._process.cpu_percent(interval=0.1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"cpu_percent": cpu_percent,
|
|
||||||
"memory_rss_mb": memory.rss / 1024 / 1024,
|
|
||||||
"memory_vms_mb": memory.vms / 1024 / 1024,
|
|
||||||
"thread_count": self._process.num_threads(),
|
|
||||||
"open_files": self._process.open_files(),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error getting system metrics: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def get_summary(self) -> Dict[str, Any]:
|
|
||||||
"""Get metrics summary"""
|
|
||||||
with self._lock:
|
|
||||||
uptime = time.time() - self._start_time
|
|
||||||
latency_pcts = self.get_latency_percentiles()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"uptime_seconds": round(uptime, 2),
|
|
||||||
"total_requests": self._request_count,
|
|
||||||
"error_count": self._error_count,
|
|
||||||
"error_rate": round(self._error_count / max(1, self._request_count) * 100, 2),
|
|
||||||
"requests_per_second": round(self._request_count / max(1, uptime), 2),
|
|
||||||
"average_latency_ms": round(self._total_latency / max(1, self._request_count) * 1000, 2),
|
|
||||||
"latency_p50_ms": round(latency_pcts["p50"] * 1000, 2),
|
|
||||||
"latency_p95_ms": round(latency_pcts["p95"] * 1000, 2),
|
|
||||||
"latency_p99_ms": round(latency_pcts["p99"] * 1000, 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
"""Reset metrics"""
|
|
||||||
with self._lock:
|
|
||||||
self._request_count = 0
|
|
||||||
self._error_count = 0
|
|
||||||
self._total_latency = 0.0
|
|
||||||
self._latencies = []
|
|
||||||
self._start_time = time.time()
|
|
||||||
|
|
||||||
|
|
||||||
class LatencyTracker:
|
|
||||||
"""Context manager for tracking operation latency"""
|
|
||||||
|
|
||||||
def __init__(self, collector: MetricsCollector, operation: str):
|
|
||||||
self.collector = collector
|
|
||||||
self.operation = operation
|
|
||||||
self.start_time: Optional[float] = None
|
|
||||||
self.success = True
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.start_time = time.time()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
latency = time.time() - self.start_time
|
|
||||||
success = exc_type is None
|
|
||||||
self.collector.record_request(latency, success)
|
|
||||||
return False # Don't suppress exceptions
|
|
||||||
|
|
||||||
|
|
||||||
# Global metrics collector
|
|
||||||
metrics_collector = MetricsCollector()
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def track_latency(operation: str = "unknown"):
|
|
||||||
"""Convenience function for latency tracking"""
|
|
||||||
with LatencyTracker(metrics_collector, operation):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def get_performance_report() -> Dict[str, Any]:
|
|
||||||
"""Generate comprehensive performance report"""
|
|
||||||
return {
|
|
||||||
"metrics": metrics_collector.get_summary(),
|
|
||||||
"system": metrics_collector.get_system_metrics(),
|
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
|
||||||
}
|
|
||||||
270
services/pdf_generator.py
Normal file
270
services/pdf_generator.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
Generador de PDFs desde texto y markdown.
|
||||||
|
|
||||||
|
Utiliza reportlab para la generación de PDFs con soporte UTF-8.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PDFGenerator:
|
||||||
|
"""Generador de PDFs desde texto plano o markdown."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Inicializa el generador de PDFs."""
|
||||||
|
self._styles = getSampleStyleSheet()
|
||||||
|
self._setup_styles()
|
||||||
|
logger.info("PDFGenerator inicializado")
|
||||||
|
|
||||||
|
def _setup_styles(self) -> None:
|
||||||
|
"""Configura los estilos personalizados para el documento."""
|
||||||
|
self._styles.add(
|
||||||
|
ParagraphStyle(
|
||||||
|
name="CustomNormal",
|
||||||
|
parent=self._styles["Normal"],
|
||||||
|
fontSize=11,
|
||||||
|
leading=14,
|
||||||
|
spaceAfter=6,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._styles.add(
|
||||||
|
ParagraphStyle(
|
||||||
|
name="CustomHeading1",
|
||||||
|
parent=self._styles["Heading1"],
|
||||||
|
fontSize=18,
|
||||||
|
leading=22,
|
||||||
|
spaceAfter=12,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._styles.add(
|
||||||
|
ParagraphStyle(
|
||||||
|
name="CustomHeading2",
|
||||||
|
parent=self._styles["Heading2"],
|
||||||
|
fontSize=14,
|
||||||
|
leading=18,
|
||||||
|
spaceAfter=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _escape_xml(self, text: str) -> str:
|
||||||
|
"""Escapa caracteres especiales para XML/HTML."""
|
||||||
|
return (
|
||||||
|
text.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\n", "<br/>")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_markdown_basic(self, markdown: str) -> list[Paragraph]:
|
||||||
|
"""
|
||||||
|
Convierte markdown básico a una lista de Paragraphs de reportlab.
|
||||||
|
|
||||||
|
Maneja: encabezados, negritas, italicas, lineas horizontales,
|
||||||
|
y saltos de linea.
|
||||||
|
"""
|
||||||
|
elements: list[Paragraph] = []
|
||||||
|
lines = markdown.split("\n")
|
||||||
|
in_list = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
elements.append(Spacer(1, 0.3 * cm))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Encabezados
|
||||||
|
if line.startswith("### "):
|
||||||
|
text = self._escape_xml(line[4:])
|
||||||
|
elements.append(
|
||||||
|
Paragraph(f"<b>{text}</b>", self._styles["CustomHeading2"])
|
||||||
|
)
|
||||||
|
elif line.startswith("## "):
|
||||||
|
text = self._escape_xml(line[3:])
|
||||||
|
elements.append(
|
||||||
|
Paragraph(f"<b>{text}</b>", self._styles["CustomHeading1"])
|
||||||
|
)
|
||||||
|
elif line.startswith("# "):
|
||||||
|
text = self._escape_xml(line[2:])
|
||||||
|
elements.append(
|
||||||
|
Paragraph(f"<b><i>{text}</i></b>", self._styles["CustomHeading1"])
|
||||||
|
)
|
||||||
|
# Línea horizontal
|
||||||
|
elif line == "---" or line == "***":
|
||||||
|
elements.append(Spacer(1, 0.2 * cm))
|
||||||
|
# Lista con guiones
|
||||||
|
elif line.startswith("- ") or line.startswith("* "):
|
||||||
|
text = self._escape_xml(line[2:])
|
||||||
|
text = f"• {self._format_inline_markdown(text)}"
|
||||||
|
elements.append(Paragraph(text, self._styles["CustomNormal"]))
|
||||||
|
# Lista numerada
|
||||||
|
elif line[0].isdigit() and ". " in line:
|
||||||
|
idx = line.index(". ")
|
||||||
|
text = self._escape_xml(line[idx + 2 :])
|
||||||
|
text = self._format_inline_markdown(text)
|
||||||
|
elements.append(Paragraph(text, self._styles["CustomNormal"]))
|
||||||
|
# Párrafo normal
|
||||||
|
else:
|
||||||
|
text = self._escape_xml(line)
|
||||||
|
text = self._format_inline_markdown(text)
|
||||||
|
elements.append(Paragraph(text, self._styles["CustomNormal"]))
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
def _format_inline_markdown(self, text: str) -> str:
|
||||||
|
"""Convierte formato inline de markdown a HTML."""
|
||||||
|
# Negritas: **texto** -> <b>texto</b>
|
||||||
|
while "**" in text:
|
||||||
|
start = text.find("**")
|
||||||
|
end = text.find("**", start + 2)
|
||||||
|
if end == -1:
|
||||||
|
break
|
||||||
|
text = (
|
||||||
|
text[:start]
|
||||||
|
+ f"<b>{text[start+2:end]}</b>"
|
||||||
|
+ text[end + 2 :]
|
||||||
|
)
|
||||||
|
# Italicas: *texto* -> <i>texto</i>
|
||||||
|
while "*" in text:
|
||||||
|
start = text.find("*")
|
||||||
|
end = text.find("*", start + 1)
|
||||||
|
if end == -1:
|
||||||
|
break
|
||||||
|
text = (
|
||||||
|
text[:start]
|
||||||
|
+ f"<i>{text[start+1:end]}</i>"
|
||||||
|
+ text[end + 1 :]
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def markdown_to_pdf(self, markdown_text: str, output_path: Path) -> Path:
|
||||||
|
"""
|
||||||
|
Convierte markdown a PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markdown_text: Contenido en formato markdown.
|
||||||
|
output_path: Ruta donde se guardará el PDF.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: Ruta del archivo PDF generado.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si el contenido está vacío.
|
||||||
|
IOError: Si hay error al escribir el archivo.
|
||||||
|
"""
|
||||||
|
if not markdown_text or not markdown_text.strip():
|
||||||
|
logger.warning("markdown_to_pdf llamado con contenido vacío")
|
||||||
|
raise ValueError("El contenido markdown no puede estar vacío")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Convirtiendo markdown a PDF",
|
||||||
|
extra={
|
||||||
|
"content_length": len(markdown_text),
|
||||||
|
"output_path": str(output_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Crear documento
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
str(output_path),
|
||||||
|
pagesize=A4,
|
||||||
|
leftMargin=2 * cm,
|
||||||
|
rightMargin=2 * cm,
|
||||||
|
topMargin=2 * cm,
|
||||||
|
bottomMargin=2 * cm,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convertir markdown a elementos
|
||||||
|
elements = self._parse_markdown_basic(markdown_text)
|
||||||
|
|
||||||
|
# Generar PDF
|
||||||
|
doc.build(elements)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"PDF generado exitosamente",
|
||||||
|
extra={"output_path": str(output_path), "pages": "unknown"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al generar PDF desde markdown: {e}")
|
||||||
|
raise IOError(f"Error al generar PDF: {e}") from e
|
||||||
|
|
||||||
|
def text_to_pdf(self, text: str, output_path: Path) -> Path:
|
||||||
|
"""
|
||||||
|
Convierte texto plano a PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Contenido de texto plano.
|
||||||
|
output_path: Ruta donde se guardará el PDF.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: Ruta del archivo PDF generado.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si el contenido está vacío.
|
||||||
|
IOError: Si hay error al escribir el archivo.
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
logger.warning("text_to_pdf llamado con contenido vacío")
|
||||||
|
raise ValueError("El contenido de texto no puede estar vacío")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Convirtiendo texto a PDF",
|
||||||
|
extra={
|
||||||
|
"content_length": len(text),
|
||||||
|
"output_path": str(output_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Crear documento
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
str(output_path),
|
||||||
|
pagesize=A4,
|
||||||
|
leftMargin=2 * cm,
|
||||||
|
rightMargin=2 * cm,
|
||||||
|
topMargin=2 * cm,
|
||||||
|
bottomMargin=2 * cm,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convertir texto a párrafos (uno por línea)
|
||||||
|
elements: list[Union[Paragraph, Spacer]] = []
|
||||||
|
lines = text.split("\n")
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
elements.append(Spacer(1, 0.3 * cm))
|
||||||
|
else:
|
||||||
|
escaped = self._escape_xml(line)
|
||||||
|
elements.append(Paragraph(escaped, self._styles["CustomNormal"]))
|
||||||
|
|
||||||
|
# Generar PDF
|
||||||
|
doc.build(elements)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"PDF generado exitosamente",
|
||||||
|
extra={"output_path": str(output_path), "pages": "unknown"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al generar PDF desde texto: {e}")
|
||||||
|
raise IOError(f"Error al generar PDF: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
# Instancia global del generador
|
||||||
|
pdf_generator = PDFGenerator()
|
||||||
@@ -1,91 +1,447 @@
|
|||||||
"""
|
"""
|
||||||
Telegram notification service
|
Servicio de notificaciones Telegram.
|
||||||
|
|
||||||
|
Envía mensajes al chat configurado mediante la API de Telegram Bot.
|
||||||
|
Silencioso si no está configurado (TELEGRAM_TOKEN y TELEGRAM_CHAT_ID).
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
try:
|
import requests
|
||||||
import requests
|
|
||||||
REQUESTS_AVAILABLE = True
|
from config.settings import settings
|
||||||
except ImportError:
|
|
||||||
REQUESTS_AVAILABLE = False
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_safely(text: str, max_length: int) -> str:
|
||||||
|
"""
|
||||||
|
Trunca texto sin romper entidades de formato HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Texto a truncar.
|
||||||
|
max_length: Longitud máxima.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Texto truncado de forma segura.
|
||||||
|
"""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Dejar margen para el sufijo "..."
|
||||||
|
safe_length = max_length - 10
|
||||||
|
|
||||||
|
# Buscar el último espacio o salto de línea antes del límite
|
||||||
|
cut_point = text.rfind("\n", 0, safe_length)
|
||||||
|
if cut_point == -1 or cut_point < safe_length - 100:
|
||||||
|
cut_point = text.rfind(" ", 0, safe_length)
|
||||||
|
if cut_point == -1 or cut_point < safe_length - 50:
|
||||||
|
cut_point = safe_length
|
||||||
|
|
||||||
|
return text[:cut_point] + "..."
|
||||||
|
|
||||||
|
|
||||||
class TelegramService:
|
class TelegramService:
|
||||||
"""Service for sending Telegram notifications"""
|
"""Servicio para enviar notificaciones a Telegram."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.logger = logging.getLogger(__name__)
|
"""Inicializa el servicio si hay configuración de Telegram."""
|
||||||
self._token: Optional[str] = None
|
self._token: Optional[str] = settings.TELEGRAM_TOKEN
|
||||||
self._chat_id: Optional[str] = None
|
self._chat_id: Optional[str] = settings.TELEGRAM_CHAT_ID
|
||||||
self._last_error_cache: dict = {}
|
self._configured: bool = settings.has_telegram_config
|
||||||
|
|
||||||
def configure(self, token: str, chat_id: str) -> None:
|
# Rate limiting: mínimo tiempo entre mensajes (segundos)
|
||||||
"""Configure Telegram credentials"""
|
self._min_interval: float = 1.0
|
||||||
self._token = token
|
self._last_send_time: float = 0.0
|
||||||
self._chat_id = chat_id
|
|
||||||
self.logger.info("Telegram service configured")
|
|
||||||
|
|
||||||
@property
|
if self._configured:
|
||||||
def is_configured(self) -> bool:
|
logger.info(
|
||||||
"""Check if Telegram is configured"""
|
"TelegramService inicializado",
|
||||||
return bool(self._token and self._chat_id)
|
extra={"chat_id": self._mask_chat_id()},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("TelegramService deshabilitado (sin configuración)")
|
||||||
|
|
||||||
def _send_request(self, endpoint: str, data: dict, retries: int = 3, delay: int = 2) -> bool:
|
def _mask_chat_id(self) -> str:
|
||||||
"""Make API request to Telegram"""
|
"""Oculta el chat_id para logging seguro."""
|
||||||
if not REQUESTS_AVAILABLE:
|
if self._chat_id and len(self._chat_id) > 4:
|
||||||
self.logger.warning("requests library not available")
|
return f"***{self._chat_id[-4:]}"
|
||||||
|
return "****"
|
||||||
|
|
||||||
|
def _wait_for_rate_limit(self) -> None:
|
||||||
|
"""Espera si es necesario para cumplir el rate limiting."""
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_send_time
|
||||||
|
if elapsed < self._min_interval:
|
||||||
|
sleep_time = self._min_interval - elapsed
|
||||||
|
logger.debug(f"Rate limiting: esperando {sleep_time:.2f}s")
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
self._last_send_time = time.monotonic()
|
||||||
|
|
||||||
|
def _send_request(self, method: str, data: dict) -> bool:
|
||||||
|
"""Envía una request a la API de Telegram."""
|
||||||
|
if not self._configured:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
url = f"https://api.telegram.org/bot{self._token}/{endpoint}"
|
url = f"https://api.telegram.org/bot{self._token}/{method}"
|
||||||
|
|
||||||
for attempt in range(retries):
|
try:
|
||||||
|
self._wait_for_rate_limit()
|
||||||
|
|
||||||
|
response = requests.post(url, json=data, timeout=10)
|
||||||
|
|
||||||
|
# Intentar parsear JSON para obtener detalles del error
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, data=data, timeout=10)
|
result = response.json()
|
||||||
if resp.status_code == 200:
|
except ValueError:
|
||||||
return True
|
result = {"raw": response.text}
|
||||||
else:
|
|
||||||
self.logger.error(f"Telegram API error: {resp.status_code}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Telegram request failed (attempt {attempt+1}/{retries}): {e}")
|
|
||||||
time.sleep(delay)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def send_message(self, message: str) -> bool:
|
if response.status_code == 200 and result.get("ok"):
|
||||||
"""Send a text message to Telegram"""
|
logger.debug(
|
||||||
if not self.is_configured:
|
"Mensaje enviado exitosamente",
|
||||||
self.logger.warning("Telegram not configured, skipping notification")
|
extra={"message_id": result.get("result", {}).get("message_id")},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Error detallado
|
||||||
|
error_code = result.get("error_code", response.status_code)
|
||||||
|
description = result.get("description", response.text)
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Error de Telegram API: HTTP {response.status_code}",
|
||||||
|
extra={
|
||||||
|
"method": method,
|
||||||
|
"error_code": error_code,
|
||||||
|
"description": description,
|
||||||
|
"response_data": result,
|
||||||
|
"request_data": {
|
||||||
|
k: v if k != "text" else f"<{len(str(v))} chars>"
|
||||||
|
for k, v in data.items()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
data = {"chat_id": self._chat_id, "text": message}
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error de conexión con Telegram: {e}",
|
||||||
|
extra={"method": method, "data_keys": list(data.keys())},
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
|
||||||
|
"""
|
||||||
|
Envía un mensaje de texto al chat configurado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Contenido del mensaje.
|
||||||
|
parse_mode: Modo de parseo (HTML, Markdown o MarkdownV2).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente, False en caso contrario.
|
||||||
|
"""
|
||||||
|
if not self._configured:
|
||||||
|
logger.debug(f"Mensaje ignorado (sin configuración): {text[:50]}...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Validar que el texto no esté vacío
|
||||||
|
if not text or not text.strip():
|
||||||
|
logger.warning("Intento de enviar mensaje vacío, ignorando")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Eliminar espacios en blanco al inicio y final
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
# Telegram limita a 4096 caracteres
|
||||||
|
MAX_LENGTH = 4096
|
||||||
|
text = _truncate_safely(text, MAX_LENGTH)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"chat_id": self._chat_id,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Solo incluir parse_mode si hay texto y no está vacío
|
||||||
|
if parse_mode:
|
||||||
|
data["parse_mode"] = parse_mode
|
||||||
|
|
||||||
|
logger.info("Enviando mensaje a Telegram", extra={"length": len(text)})
|
||||||
return self._send_request("sendMessage", data)
|
return self._send_request("sendMessage", data)
|
||||||
|
|
||||||
def send_start_notification(self) -> bool:
|
def send_start_notification(self, filename: str) -> bool:
|
||||||
"""Send service start notification"""
|
"""
|
||||||
message = "CBCFacil Service Started - AI document processing active"
|
Envía notificación de inicio de procesamiento.
|
||||||
return self.send_message(message)
|
|
||||||
|
|
||||||
def send_error_notification(self, error_key: str, error_message: str) -> bool:
|
Args:
|
||||||
"""Send error notification with throttling"""
|
filename: Nombre del archivo que se está procesando.
|
||||||
now = datetime.utcnow()
|
|
||||||
prev = self._last_error_cache.get(error_key)
|
Returns:
|
||||||
if prev is None:
|
True si se envió correctamente.
|
||||||
self._last_error_cache[error_key] = (error_message, now)
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
# Usar HTML para evitar problemas de escaping
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
text = f"▶️ <b>Inicio de procesamiento</b>\n\n📄 Archivo: <code>{safe_filename}</code>"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_error_notification(self, filename: str, error: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de error en procesamiento.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo que falló.
|
||||||
|
error: Descripción del error.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
if not error:
|
||||||
|
error = "(error desconocido)"
|
||||||
|
|
||||||
|
# Usar HTML para evitar problemas de escaping
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
safe_error = error.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
text = f"❌ <b>Error de procesamiento</b>\n\n📄 Archivo: <code>{safe_filename}</code>\n⚠️ Error: {safe_error}"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_completion_notification(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de completado exitoso.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo procesado.
|
||||||
|
duration: Duración del procesamiento en segundos (opcional).
|
||||||
|
output_path: Ruta del archivo de salida (opcional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
# Usar HTML para evitar problemas de escaping
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
duration_text = ""
|
||||||
|
if duration is not None:
|
||||||
|
minutes = int(duration // 60)
|
||||||
|
seconds = int(duration % 60)
|
||||||
|
duration_text = f"\n⏱️ Duración: {minutes}m {seconds}s"
|
||||||
|
|
||||||
|
output_text = ""
|
||||||
|
if output_path:
|
||||||
|
safe_output = output_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
output_text = f"\n📁 Salida: <code>{safe_output}</code>"
|
||||||
|
|
||||||
|
text = f"✅ <b>Procesamiento completado</b>\n\n📄 Archivo: <code>{safe_filename}</code>{duration_text}{output_text}"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_download_complete(self, filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de descarga completada.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo descargado.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = f"📥 <b>Archivo descargado</b>\n\n📄 <code>{safe_filename}</code>"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_transcription_start(self, filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de inicio de transcripción.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo a transcribir.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = f"🎙️ <b>Iniciando transcripción...</b>\n\n📄 <code>{safe_filename}</code>"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_transcription_progress(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
progress_percent: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de progreso de transcripción.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo.
|
||||||
|
progress_percent: Porcentaje de progreso (0-100).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = f"⏳ <b>Transcribiendo...</b>\n\n📄 <code>{safe_filename}</code>\n📊 Progreso: {progress_percent}%"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_transcription_complete(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
text_length: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de transcripción completada.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo.
|
||||||
|
text_length: Longitud del texto transcrito.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
# Formatear longitud del texto
|
||||||
|
if text_length >= 1000:
|
||||||
|
length_text = f"{text_length // 1000}k caracteres"
|
||||||
else:
|
else:
|
||||||
prev_msg, prev_time = prev
|
length_text = f"{text_length} caracteres"
|
||||||
if error_message != prev_msg or (now - prev_time).total_seconds() > settings.ERROR_THROTTLE_SECONDS:
|
|
||||||
self._last_error_cache[error_key] = (error_message, now)
|
text = f"✅ <b>Transcripción completada</b>\n\n📄 <code>{safe_filename}</code>\n📝 {length_text}"
|
||||||
else:
|
return self.send_message(text, parse_mode="HTML")
|
||||||
return False
|
|
||||||
return self.send_message(f"Error: {error_message}")
|
def send_summary_start(self, filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de inicio de resumen con IA.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = f"🤖 <b>Generando resumen con IA...</b>\n\n📄 <code>{safe_filename}</code>"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_summary_complete(self, filename: str, has_markdown: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de resumen completado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo.
|
||||||
|
has_markdown: Si se creó el archivo markdown.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
status = "✅" if has_markdown else "⚠️"
|
||||||
|
text = f"{status} <b>Resumen completado</b>\n\n📄 <code>{safe_filename}</code>"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_pdf_start(self, filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de inicio de generación de PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = f"📄 <b>Creando PDF...</b>\n\n📄 <code>{safe_filename}</code>"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_pdf_complete(self, filename: str, pdf_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación de PDF completado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo.
|
||||||
|
pdf_path: Ruta del PDF generado.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
safe_path = pdf_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = f"📄 <b>PDF creado</b>\n\n📄 <code>{safe_filename}</code>\n📁 <code>{safe_path}</code>"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
def send_all_complete(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
txt_path: Optional[str] = None,
|
||||||
|
md_path: Optional[str] = None,
|
||||||
|
pdf_path: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Envía notificación final con todos los archivos generados.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Nombre del archivo original.
|
||||||
|
txt_path: Ruta del archivo de texto (opcional).
|
||||||
|
md_path: Ruta del markdown (opcional).
|
||||||
|
pdf_path: Ruta del PDF (opcional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se envió correctamente.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
filename = "(desconocido)"
|
||||||
|
|
||||||
|
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
files_text = ""
|
||||||
|
if txt_path:
|
||||||
|
safe_txt = txt_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
files_text += f"\n📝 <code>{safe_txt}</code>"
|
||||||
|
if md_path:
|
||||||
|
safe_md = md_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
files_text += f"\n📋 <code>{safe_md}</code>"
|
||||||
|
if pdf_path:
|
||||||
|
safe_pdf = pdf_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
files_text += f"\n📄 <code>{safe_pdf}</code>"
|
||||||
|
|
||||||
|
text = f"✅ <b>¡Proceso completado!</b>\n\n📄 <code>{safe_filename}</code>\n📁 Archivos:{files_text}"
|
||||||
|
return self.send_message(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Instancia global del servicio
|
||||||
telegram_service = TelegramService()
|
telegram_service = TelegramService()
|
||||||
|
|
||||||
|
|
||||||
def send_telegram_message(message: str, retries: int = 3, delay: int = 2) -> bool:
|
|
||||||
"""Legacy function for backward compatibility"""
|
|
||||||
return telegram_service.send_message(message)
|
|
||||||
|
|||||||
@@ -1,172 +1,307 @@
|
|||||||
"""
|
"""
|
||||||
VRAM/GPU memory management service
|
Gestor de VRAM para descargar modelos de ML inactivos.
|
||||||
|
|
||||||
|
Proporciona limpieza automática de modelos (como Whisper) que no han sido
|
||||||
|
usados durante un tiempo configurable para liberar memoria VRAM.
|
||||||
|
|
||||||
|
OPTIMIZACIONES:
|
||||||
|
- Integración con cache global de modelos
|
||||||
|
- Limpieza agresiva de cache CUDA
|
||||||
|
- Monitoreo de memoria en tiempo real
|
||||||
"""
|
"""
|
||||||
import gc
|
import gc
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from typing import Callable, Dict, Optional
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from core import BaseService
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
try:
|
from config.settings import settings
|
||||||
import torch
|
|
||||||
TORCH_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
TORCH_AVAILABLE = False
|
|
||||||
|
|
||||||
# Import gpu_detector after torch check
|
logger = logging.getLogger(__name__)
|
||||||
from .gpu_detector import gpu_detector, GPUType
|
|
||||||
|
|
||||||
|
|
||||||
class VRAMManager(BaseService):
|
def get_gpu_memory_mb() -> Dict[str, float]:
|
||||||
"""Service for managing GPU VRAM usage"""
|
"""
|
||||||
|
Obtiene uso de memoria GPU en MB.
|
||||||
|
|
||||||
def __init__(self):
|
Returns:
|
||||||
super().__init__("VRAMManager")
|
Dict con 'total', 'used', 'free' en MB.
|
||||||
self._whisper_model = None
|
"""
|
||||||
self._ocr_models = None
|
try:
|
||||||
self._trocr_models = None
|
import torch
|
||||||
self._models_last_used: Optional[datetime] = None
|
|
||||||
self._cleanup_threshold = 0.7
|
|
||||||
self._cleanup_interval = 300
|
|
||||||
self._last_cleanup: Optional[datetime] = None
|
|
||||||
|
|
||||||
def initialize(self) -> None:
|
if torch.cuda.is_available():
|
||||||
"""Initialize VRAM manager"""
|
props = torch.cuda.get_device_properties(0)
|
||||||
# Initialize GPU detector first
|
total = props.total_memory / (1024 ** 2)
|
||||||
gpu_detector.initialize()
|
allocated = torch.cuda.memory_allocated(0) / (1024 ** 2)
|
||||||
|
reserved = torch.cuda.memory_reserved(0) / (1024 ** 2)
|
||||||
if not TORCH_AVAILABLE:
|
|
||||||
self.logger.warning("PyTorch not available - VRAM management disabled")
|
|
||||||
return
|
|
||||||
|
|
||||||
if gpu_detector.is_available():
|
return {
|
||||||
gpu_type = gpu_detector.gpu_type
|
"total": total,
|
||||||
device_name = gpu_detector.get_device_name()
|
"used": allocated,
|
||||||
|
"free": total - reserved,
|
||||||
if gpu_type == GPUType.AMD:
|
"reserved": reserved,
|
||||||
self.logger.info(f"VRAM Manager initialized with AMD ROCm: {device_name}")
|
}
|
||||||
elif gpu_type == GPUType.NVIDIA:
|
except ImportError:
|
||||||
os.environ['CUDA_VISIBLE_DEVICES'] = settings.CUDA_VISIBLE_DEVICES
|
pass
|
||||||
if settings.PYTORCH_CUDA_ALLOC_CONF:
|
except Exception as e:
|
||||||
torch.backends.cuda.max_split_size_mb = int(settings.PYTORCH_CUDA_ALLOC_CONF.split(':')[1])
|
logger.debug(f"Error obteniendo memoria GPU: {e}")
|
||||||
self.logger.info(f"VRAM Manager initialized with NVIDIA CUDA: {device_name}")
|
|
||||||
else:
|
|
||||||
self.logger.warning("No GPU available - GPU acceleration disabled")
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
return {"total": 0, "used": 0, "free": 0, "reserved": 0}
|
||||||
"""Cleanup all GPU models"""
|
|
||||||
if not TORCH_AVAILABLE or not torch.cuda.is_available():
|
|
||||||
return
|
|
||||||
|
|
||||||
models_freed = []
|
|
||||||
|
|
||||||
if self._whisper_model is not None:
|
def clear_cuda_cache(aggressive: bool = False) -> None:
|
||||||
try:
|
"""
|
||||||
del self._whisper_model
|
Limpia el cache de CUDA.
|
||||||
self._whisper_model = None
|
|
||||||
models_freed.append("Whisper")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error freeing Whisper VRAM: {e}")
|
|
||||||
|
|
||||||
if self._ocr_models is not None:
|
Args:
|
||||||
try:
|
aggressive: Si True, ejecuta gc.collect() múltiples veces.
|
||||||
self._ocr_models = None
|
"""
|
||||||
models_freed.append("OCR")
|
try:
|
||||||
except Exception as e:
|
import torch
|
||||||
self.logger.error(f"Error freeing OCR VRAM: {e}")
|
|
||||||
|
|
||||||
if self._trocr_models is not None:
|
if torch.cuda.is_available():
|
||||||
try:
|
|
||||||
if isinstance(self._trocr_models, dict):
|
|
||||||
model = self._trocr_models.get('model')
|
|
||||||
if model is not None:
|
|
||||||
model.to('cpu')
|
|
||||||
models_freed.append("TrOCR")
|
|
||||||
torch.cuda.empty_cache()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error freeing TrOCR VRAM: {e}")
|
|
||||||
|
|
||||||
self._whisper_model = None
|
|
||||||
self._ocr_models = None
|
|
||||||
self._trocr_models = None
|
|
||||||
self._models_last_used = None
|
|
||||||
|
|
||||||
if models_freed:
|
|
||||||
self.logger.info(f"Freed VRAM for models: {', '.join(models_freed)}")
|
|
||||||
|
|
||||||
self._force_aggressive_cleanup()
|
|
||||||
|
|
||||||
def update_usage(self) -> None:
|
|
||||||
"""Update usage timestamp"""
|
|
||||||
self._models_last_used = datetime.utcnow()
|
|
||||||
self.logger.debug(f"VRAM usage timestamp updated")
|
|
||||||
|
|
||||||
def should_cleanup(self) -> bool:
|
|
||||||
"""Check if cleanup should be performed"""
|
|
||||||
if not TORCH_AVAILABLE or not torch.cuda.is_available():
|
|
||||||
return False
|
|
||||||
if self._last_cleanup is None:
|
|
||||||
return True
|
|
||||||
if (datetime.utcnow() - self._last_cleanup).total_seconds() < self._cleanup_interval:
|
|
||||||
return False
|
|
||||||
allocated = torch.cuda.memory_allocated(0)
|
|
||||||
total = torch.cuda.get_device_properties(0).total_memory
|
|
||||||
return allocated / total > self._cleanup_threshold
|
|
||||||
|
|
||||||
def lazy_cleanup(self) -> None:
|
|
||||||
"""Perform cleanup if needed"""
|
|
||||||
if self.should_cleanup():
|
|
||||||
self.cleanup()
|
|
||||||
self._last_cleanup = datetime.utcnow()
|
|
||||||
|
|
||||||
def _force_aggressive_cleanup(self) -> None:
|
|
||||||
"""Force aggressive VRAM cleanup"""
|
|
||||||
if not TORCH_AVAILABLE or not torch.cuda.is_available():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
before_allocated = torch.cuda.memory_allocated(0) / 1024**3
|
|
||||||
before_reserved = torch.cuda.memory_reserved(0) / 1024**3
|
|
||||||
self.logger.debug(f"Before cleanup - Allocated: {before_allocated:.2f}GB, Reserved: {before_reserved:.2f}GB")
|
|
||||||
gc.collect(0)
|
|
||||||
torch.cuda.empty_cache()
|
torch.cuda.empty_cache()
|
||||||
after_allocated = torch.cuda.memory_allocated(0) / 1024**3
|
|
||||||
after_reserved = torch.cuda.memory_reserved(0) / 1024**3
|
if aggressive:
|
||||||
self.logger.debug(f"After cleanup - Allocated: {after_allocated:.2f}GB, Reserved: {after_reserved:.2f}GB")
|
for _ in range(3):
|
||||||
if after_reserved < before_reserved:
|
gc.collect()
|
||||||
self.logger.info(f"VRAM freed: {(before_reserved - after_reserved):.2f}GB")
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"CUDA cache limpiada",
|
||||||
|
extra={"aggressive": aggressive, "memory_mb": get_gpu_memory_mb()},
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VRAMManager:
|
||||||
|
"""
|
||||||
|
Gestor singleton para administrar la descarga automática de modelos.
|
||||||
|
|
||||||
|
Mantiene registro del último uso de cada modelo y proporciona métodos
|
||||||
|
para verificar y limpiar modelos inactivos.
|
||||||
|
|
||||||
|
NOTA: Con el nuevo cache global de modelos, este gestor ya no fuerza
|
||||||
|
la descarga del modelo en sí, solo coordina los tiempos de cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance: Optional["VRAMManager"] = None
|
||||||
|
|
||||||
|
def __new__(cls) -> "VRAMManager":
|
||||||
|
"""Implementación del patrón Singleton."""
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Inicializa el gestor si no ha sido inicializado."""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_usage: Dict[str, float] = {}
|
||||||
|
self._unload_callbacks: Dict[str, Callable[[], None]] = {}
|
||||||
|
self._auto_unload_seconds = settings.WHISPER_AUTO_UNLOAD_SECONDS
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"VRAMManager inicializado",
|
||||||
|
extra={"auto_unload_seconds": self._auto_unload_seconds},
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_model(
|
||||||
|
self, model_id: str, unload_callback: Callable[[], None]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Registra un modelo con su callback de descarga.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador único del modelo.
|
||||||
|
unload_callback: Función a llamar para descargar el modelo.
|
||||||
|
"""
|
||||||
|
self._unload_callbacks[model_id] = unload_callback
|
||||||
|
self._last_usage[model_id] = time.time()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Modelo registrado en VRAMManager",
|
||||||
|
extra={"model_id": model_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_usage(self, model_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Actualiza el timestamp del último uso del modelo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador del modelo.
|
||||||
|
"""
|
||||||
|
self._last_usage[model_id] = time.time()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Uso actualizado",
|
||||||
|
extra={"model_id": model_id, "memory_mb": get_gpu_memory_mb()},
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_used(self, model_id: str = "default") -> None:
|
||||||
|
"""
|
||||||
|
Marca el modelo como usado (alias simple para update_usage).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador del modelo. Default: "default".
|
||||||
|
"""
|
||||||
|
self.update_usage(model_id)
|
||||||
|
|
||||||
|
def check_and_cleanup(
|
||||||
|
self, model_id: str, timeout_seconds: Optional[int] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Verifica si el modelo debe ser descargado y lo limpia si es necesario.
|
||||||
|
|
||||||
|
NOTA: Con el cache global, la descarga solo elimina la referencia
|
||||||
|
local. El modelo puede permanecer en cache para otras instancias.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador del modelo a verificar.
|
||||||
|
timeout_seconds: Tiempo máximo de inactividad en segundos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si el modelo fue descargado, False si no necesitaba descarga.
|
||||||
|
"""
|
||||||
|
if model_id not in self._unload_callbacks:
|
||||||
|
logger.warning(
|
||||||
|
"Modelo no registrado en VRAMManager",
|
||||||
|
extra={"model_id": model_id},
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
threshold = timeout_seconds or self._auto_unload_seconds
|
||||||
|
last_used = self._last_usage.get(model_id, 0)
|
||||||
|
elapsed = time.time() - last_used
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Verificando modelo",
|
||||||
|
extra={
|
||||||
|
"model_id": model_id,
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
"threshold_seconds": threshold,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if elapsed >= threshold:
|
||||||
|
return self._unload_model(model_id)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _unload_model(self, model_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Descarga el modelo invocando su callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador del modelo a descargar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si la descarga fue exitosa.
|
||||||
|
"""
|
||||||
|
callback = self._unload_callbacks.get(model_id)
|
||||||
|
if callback is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
callback()
|
||||||
|
|
||||||
|
# Limpiar cache de CUDA después de descargar
|
||||||
|
clear_cuda_cache(aggressive=True)
|
||||||
|
|
||||||
|
# Limpiar registro después de descarga exitosa
|
||||||
|
self._unload_callbacks.pop(model_id, None)
|
||||||
|
self._last_usage.pop(model_id, None)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Modelo descargado por VRAMManager",
|
||||||
|
extra={
|
||||||
|
"model_id": model_id,
|
||||||
|
"reason": "inactive",
|
||||||
|
"memory_mb_after": get_gpu_memory_mb(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error in aggressive VRAM cleanup: {e}")
|
logger.error(
|
||||||
|
"Error al descargar modelo",
|
||||||
|
extra={"model_id": model_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def get_usage(self) -> Dict[str, Any]:
|
def force_unload(self, model_id: str) -> bool:
|
||||||
"""Get VRAM usage information"""
|
"""
|
||||||
if not TORCH_AVAILABLE:
|
Fuerza la descarga inmediata de un modelo.
|
||||||
return {'error': 'PyTorch not available'}
|
|
||||||
if not torch.cuda.is_available():
|
|
||||||
return {'error': 'CUDA not available'}
|
|
||||||
total = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
|
||||||
allocated = torch.cuda.memory_allocated(0) / 1024**3
|
|
||||||
cached = torch.cuda.memory_reserved(0) / 1024**3
|
|
||||||
free = total - allocated
|
|
||||||
return {
|
|
||||||
'total_gb': round(total, 2),
|
|
||||||
'allocated_gb': round(allocated, 2),
|
|
||||||
'cached_gb': round(cached, 2),
|
|
||||||
'free_gb': round(free, 2),
|
|
||||||
'whisper_loaded': self._whisper_model is not None,
|
|
||||||
'ocr_models_loaded': self._ocr_models is not None,
|
|
||||||
'trocr_models_loaded': self._trocr_models is not None,
|
|
||||||
'last_used': self._models_last_used.isoformat() if self._models_last_used else None,
|
|
||||||
'timeout_seconds': settings.MODEL_TIMEOUT_SECONDS
|
|
||||||
}
|
|
||||||
|
|
||||||
def force_free(self) -> str:
|
Args:
|
||||||
"""Force immediate VRAM free"""
|
model_id: Identificador del modelo a descargar.
|
||||||
self.cleanup()
|
|
||||||
return "VRAM freed successfully"
|
Returns:
|
||||||
|
True si la descarga fue exitosa.
|
||||||
|
"""
|
||||||
|
return self._unload_model(model_id)
|
||||||
|
|
||||||
|
def get_memory_info(self) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
Obtiene información actual de memoria GPU.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con 'total', 'used', 'free', 'reserved' en MB.
|
||||||
|
"""
|
||||||
|
return get_gpu_memory_mb()
|
||||||
|
|
||||||
|
def get_last_usage(self, model_id: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Obtiene el timestamp del último uso del modelo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador del modelo.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timestamp del último uso o None si no existe.
|
||||||
|
"""
|
||||||
|
return self._last_usage.get(model_id)
|
||||||
|
|
||||||
|
def get_seconds_since_last_use(self, model_id: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Obtiene los segundos transcurridos desde el último uso.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador del modelo.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Segundos transcurridos o None si no existe.
|
||||||
|
"""
|
||||||
|
last_used = self._last_usage.get(model_id)
|
||||||
|
if last_used is None:
|
||||||
|
return None
|
||||||
|
return time.time() - last_used
|
||||||
|
|
||||||
|
def unregister_model(self, model_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Elimina el registro de un modelo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: Identificador del modelo a eliminar.
|
||||||
|
"""
|
||||||
|
self._unload_callbacks.pop(model_id, None)
|
||||||
|
self._last_usage.pop(model_id, None)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Modelo eliminado de VRAMManager",
|
||||||
|
extra={"model_id": model_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
"""Limpia todos los registros del gestor."""
|
||||||
|
self._unload_callbacks.clear()
|
||||||
|
self._last_usage.clear()
|
||||||
|
logger.info("VRAMManager limpiado")
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Instancia global singleton
|
||||||
vram_manager = VRAMManager()
|
vram_manager = VRAMManager()
|
||||||
|
|||||||
@@ -1,215 +1,102 @@
|
|||||||
"""
|
"""
|
||||||
WebDAV service for Nextcloud integration
|
Cliente WebDAV para Nextcloud.
|
||||||
|
Provee métodos para interactuar con Nextcloud via WebDAV.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import unicodedata
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional
|
||||||
from contextlib import contextmanager
|
from webdav3.client import Client
|
||||||
import requests
|
|
||||||
from requests.auth import HTTPBasicAuth
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from core import WebDAVError
|
|
||||||
|
|
||||||
|
|
||||||
class WebDAVService:
|
class WebDAVService:
|
||||||
"""Service for WebDAV operations with Nextcloud"""
|
"""Cliente WebDAV para Nextcloud."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.session: Optional[requests.Session] = None
|
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self._retry_delay = 1
|
self._client: Optional[Client] = None
|
||||||
self._max_retries = settings.WEBDAV_MAX_RETRIES
|
|
||||||
|
|
||||||
def initialize(self) -> None:
|
def _get_client(self) -> Client:
|
||||||
"""Initialize WebDAV session"""
|
"""Obtiene o crea el cliente WebDAV."""
|
||||||
if not settings.has_webdav_config:
|
if self._client is None:
|
||||||
raise WebDAVError("WebDAV credentials not configured")
|
if not settings.has_webdav_config:
|
||||||
|
raise RuntimeError("WebDAV configuration missing")
|
||||||
|
|
||||||
self.session = requests.Session()
|
options = {
|
||||||
self.session.auth = HTTPBasicAuth(settings.NEXTCLOUD_USER, settings.NEXTCLOUD_PASSWORD)
|
"webdav_hostname": settings.NEXTCLOUD_URL,
|
||||||
|
"webdav_login": settings.NEXTCLOUD_USER,
|
||||||
|
"webdav_password": settings.NEXTCLOUD_PASSWORD,
|
||||||
|
}
|
||||||
|
self._client = Client(options)
|
||||||
|
self._client.verify = True # Verificar SSL
|
||||||
|
|
||||||
# Configure HTTP adapter with retry strategy
|
return self._client
|
||||||
adapter = HTTPAdapter(
|
|
||||||
max_retries=0, # We'll handle retries manually
|
|
||||||
pool_connections=10,
|
|
||||||
pool_maxsize=20
|
|
||||||
)
|
|
||||||
self.session.mount('https://', adapter)
|
|
||||||
self.session.mount('http://', adapter)
|
|
||||||
|
|
||||||
# Test connection
|
def test_connection(self) -> bool:
|
||||||
|
"""Prueba la conexión con Nextcloud."""
|
||||||
try:
|
try:
|
||||||
self._request('GET', '', timeout=5)
|
client = self._get_client()
|
||||||
self.logger.info("WebDAV connection established")
|
return client.check()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebDAVError(f"Failed to connect to WebDAV: {e}")
|
self.logger.error(f"WebDAV connection failed: {e}")
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""Cleanup WebDAV session"""
|
|
||||||
if self.session:
|
|
||||||
self.session.close()
|
|
||||||
self.session = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def normalize_path(path: str) -> str:
|
|
||||||
"""Normalize remote paths to a consistent representation"""
|
|
||||||
if not path:
|
|
||||||
return ""
|
|
||||||
normalized = unicodedata.normalize("NFC", str(path)).strip()
|
|
||||||
if not normalized:
|
|
||||||
return ""
|
|
||||||
normalized = normalized.replace("\\", "/")
|
|
||||||
normalized = re.sub(r"/+", "/", normalized)
|
|
||||||
return normalized.lstrip("/")
|
|
||||||
|
|
||||||
def _build_url(self, remote_path: str) -> str:
|
|
||||||
"""Build WebDAV URL"""
|
|
||||||
path = self.normalize_path(remote_path)
|
|
||||||
base_url = settings.WEBDAV_ENDPOINT.rstrip('/')
|
|
||||||
return f"{base_url}/{path}"
|
|
||||||
|
|
||||||
def _request(self, method: str, remote_path: str, **kwargs) -> requests.Response:
|
|
||||||
"""Make HTTP request to WebDAV with retries"""
|
|
||||||
if not self.session:
|
|
||||||
raise WebDAVError("WebDAV session not initialized")
|
|
||||||
|
|
||||||
url = self._build_url(remote_path)
|
|
||||||
timeout = kwargs.pop('timeout', settings.HTTP_TIMEOUT)
|
|
||||||
|
|
||||||
for attempt in range(self._max_retries):
|
|
||||||
try:
|
|
||||||
response = self.session.request(method, url, timeout=timeout, **kwargs)
|
|
||||||
if response.status_code < 400:
|
|
||||||
return response
|
|
||||||
elif response.status_code == 404:
|
|
||||||
raise WebDAVError(f"Resource not found: {remote_path}")
|
|
||||||
else:
|
|
||||||
raise WebDAVError(f"HTTP {response.status_code}: {response.text}")
|
|
||||||
except (requests.RequestException, requests.Timeout) as e:
|
|
||||||
if attempt == self._max_retries - 1:
|
|
||||||
raise WebDAVError(f"Request failed after {self._max_retries} retries: {e}")
|
|
||||||
delay = self._retry_delay * (2 ** attempt)
|
|
||||||
self.logger.warning(f"Request failed (attempt {attempt + 1}/{self._max_retries}), retrying in {delay}s...")
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
raise WebDAVError("Max retries exceeded")
|
|
||||||
|
|
||||||
def list(self, remote_path: str = "") -> List[str]:
|
|
||||||
"""List files in remote directory"""
|
|
||||||
self.logger.debug(f"Listing remote directory: {remote_path}")
|
|
||||||
response = self._request('PROPFIND', remote_path, headers={'Depth': '1'})
|
|
||||||
return self._parse_propfind_response(response.text)
|
|
||||||
|
|
||||||
def _parse_propfind_response(self, xml_response: str) -> List[str]:
|
|
||||||
"""Parse PROPFIND XML response"""
|
|
||||||
# Simple parser for PROPFIND response
|
|
||||||
files = []
|
|
||||||
try:
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from urllib.parse import urlparse, unquote
|
|
||||||
root = ET.fromstring(xml_response)
|
|
||||||
|
|
||||||
# Get the WebDAV path from settings
|
|
||||||
parsed_url = urlparse(settings.NEXTCLOUD_URL)
|
|
||||||
webdav_path = parsed_url.path.rstrip('/') # e.g. /remote.php/webdav
|
|
||||||
|
|
||||||
# Find all href elements
|
|
||||||
for href in root.findall('.//{DAV:}href'):
|
|
||||||
href_text = href.text or ""
|
|
||||||
href_text = unquote(href_text) # Decode URL encoding
|
|
||||||
|
|
||||||
# Remove base URL from href
|
|
||||||
base_url = settings.NEXTCLOUD_URL.rstrip('/')
|
|
||||||
if href_text.startswith(base_url):
|
|
||||||
href_text = href_text[len(base_url):]
|
|
||||||
|
|
||||||
# Also strip the webdav path if it's there
|
|
||||||
if href_text.startswith(webdav_path):
|
|
||||||
href_text = href_text[len(webdav_path):]
|
|
||||||
|
|
||||||
# Clean up the path
|
|
||||||
href_text = href_text.lstrip('/')
|
|
||||||
if href_text: # Skip empty paths (root directory)
|
|
||||||
files.append(href_text)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error parsing PROPFIND response: {e}")
|
|
||||||
|
|
||||||
return files
|
|
||||||
|
|
||||||
def download(self, remote_path: str, local_path: Path) -> None:
|
|
||||||
"""Download file from WebDAV"""
|
|
||||||
self.logger.info(f"Downloading {remote_path} to {local_path}")
|
|
||||||
|
|
||||||
# Ensure local directory exists
|
|
||||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
response = self._request('GET', remote_path, stream=True)
|
|
||||||
|
|
||||||
# Use larger buffer size for better performance
|
|
||||||
with open(local_path, 'wb', buffering=65536) as f:
|
|
||||||
for chunk in response.iter_content(chunk_size=settings.DOWNLOAD_CHUNK_SIZE):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
self.logger.debug(f"Download completed: {local_path}")
|
|
||||||
|
|
||||||
def upload(self, local_path: Path, remote_path: str) -> None:
|
|
||||||
"""Upload file to WebDAV"""
|
|
||||||
self.logger.info(f"Uploading {local_path} to {remote_path}")
|
|
||||||
|
|
||||||
# Ensure remote directory exists
|
|
||||||
remote_dir = self.normalize_path(remote_path)
|
|
||||||
if '/' in remote_dir:
|
|
||||||
dir_path = '/'.join(remote_dir.split('/')[:-1])
|
|
||||||
self.makedirs(dir_path)
|
|
||||||
|
|
||||||
with open(local_path, 'rb') as f:
|
|
||||||
self._request('PUT', remote_path, data=f)
|
|
||||||
|
|
||||||
self.logger.debug(f"Upload completed: {remote_path}")
|
|
||||||
|
|
||||||
def mkdir(self, remote_path: str) -> None:
|
|
||||||
"""Create directory on WebDAV"""
|
|
||||||
self.makedirs(remote_path)
|
|
||||||
|
|
||||||
def makedirs(self, remote_path: str) -> None:
|
|
||||||
"""Create directory and parent directories on WebDAV"""
|
|
||||||
path = self.normalize_path(remote_path)
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
|
|
||||||
parts = path.split('/')
|
|
||||||
current = ""
|
|
||||||
|
|
||||||
for part in parts:
|
|
||||||
current = f"{current}/{part}" if current else part
|
|
||||||
try:
|
|
||||||
self._request('MKCOL', current)
|
|
||||||
self.logger.debug(f"Created directory: {current}")
|
|
||||||
except WebDAVError as e:
|
|
||||||
# Directory might already exist (409 Conflict or 405 MethodNotAllowed is OK)
|
|
||||||
if '409' not in str(e) and '405' not in str(e):
|
|
||||||
raise
|
|
||||||
|
|
||||||
def delete(self, remote_path: str) -> None:
|
|
||||||
"""Delete file or directory from WebDAV"""
|
|
||||||
self.logger.info(f"Deleting remote path: {remote_path}")
|
|
||||||
self._request('DELETE', remote_path)
|
|
||||||
|
|
||||||
def exists(self, remote_path: str) -> bool:
|
|
||||||
"""Check if remote path exists"""
|
|
||||||
try:
|
|
||||||
self._request('HEAD', remote_path)
|
|
||||||
return True
|
|
||||||
except WebDAVError:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def list_files(self, remote_path: str = "/") -> list[str]:
|
||||||
|
"""Lista archivos en una ruta remota."""
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
# Asegurar que la ruta empieza con /
|
||||||
|
if not remote_path.startswith("/"):
|
||||||
|
remote_path = "/" + remote_path
|
||||||
|
|
||||||
# Global instance
|
files = client.list(remote_path)
|
||||||
webdav_service = WebDAVService()
|
return files if files else []
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to list files: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def download_file(self, remote_path: str, local_path: Path) -> bool:
|
||||||
|
"""Descarga un archivo desde Nextcloud."""
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
client.download_sync(remote_path=str(remote_path), local_path=str(local_path))
|
||||||
|
self.logger.info(f"Downloaded: {remote_path} -> {local_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to download {remote_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_file_info(self, remote_path: str) -> dict:
|
||||||
|
"""Obtiene información de un archivo."""
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
info = client.info(remote_path)
|
||||||
|
return {
|
||||||
|
"name": info.get("name", ""),
|
||||||
|
"size": info.get("size", 0),
|
||||||
|
"modified": info.get("modified", ""),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get file info: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def file_exists(self, remote_path: str) -> bool:
|
||||||
|
"""Verifica si un archivo existe en remoto."""
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
return client.check(remote_path)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def upload_file(self, local_path: Path, remote_path: str) -> bool:
|
||||||
|
"""Sube un archivo a Nextcloud."""
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
client.upload_sync(local_path=str(local_path), remote_path=str(remote_path))
|
||||||
|
self.logger.info(f"Uploaded: {local_path} -> {remote_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to upload {local_path}: {e}")
|
||||||
|
return False
|
||||||
|
|||||||
165
setup.py
165
setup.py
@@ -1,165 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Setup script for CBCFacil
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import platform
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def check_python_version():
|
|
||||||
"""Check if Python version is 3.10 or higher"""
|
|
||||||
if sys.version_info < (3, 10):
|
|
||||||
print("❌ Error: Python 3.10 or higher is required")
|
|
||||||
print(f" Current version: {sys.version}")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"✓ Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
|
||||||
|
|
||||||
|
|
||||||
def check_system_dependencies():
|
|
||||||
"""Check and install system dependencies"""
|
|
||||||
system = platform.system().lower()
|
|
||||||
|
|
||||||
print("\n📦 Checking system dependencies...")
|
|
||||||
|
|
||||||
if system == "linux":
|
|
||||||
# Check for CUDA (optional)
|
|
||||||
if os.path.exists("/usr/local/cuda"):
|
|
||||||
print("✓ CUDA found")
|
|
||||||
else:
|
|
||||||
print("⚠ CUDA not found - GPU acceleration will be disabled")
|
|
||||||
|
|
||||||
# Check for tesseract
|
|
||||||
try:
|
|
||||||
subprocess.run(["tesseract", "--version"], check=True, capture_output=True)
|
|
||||||
print("✓ Tesseract OCR installed")
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
print("⚠ Tesseract OCR not found - PDF processing may not work")
|
|
||||||
|
|
||||||
# Check for ffmpeg
|
|
||||||
try:
|
|
||||||
subprocess.run(["ffmpeg", "-version"], check=True, capture_output=True)
|
|
||||||
print("✓ FFmpeg installed")
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
print("⚠ FFmpeg not found - audio processing may not work")
|
|
||||||
|
|
||||||
elif system == "darwin": # macOS
|
|
||||||
# Check for tesseract
|
|
||||||
try:
|
|
||||||
subprocess.run(["brew", "list", "tesseract"], check=True, capture_output=True)
|
|
||||||
print("✓ Tesseract OCR installed")
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
print("⚠ Tesseract not found. Install with: brew install tesseract")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def create_virtual_environment():
|
|
||||||
"""Create Python virtual environment"""
|
|
||||||
venv_path = Path("venv")
|
|
||||||
|
|
||||||
if venv_path.exists():
|
|
||||||
print("✓ Virtual environment already exists")
|
|
||||||
return venv_path
|
|
||||||
|
|
||||||
print("📦 Creating virtual environment...")
|
|
||||||
subprocess.run([sys.executable, "-m", "venv", "venv"], check=True)
|
|
||||||
print("✓ Virtual environment created")
|
|
||||||
return venv_path
|
|
||||||
|
|
||||||
|
|
||||||
def install_requirements(venv_path):
|
|
||||||
"""Install Python requirements"""
|
|
||||||
pip_path = venv_path / ("Scripts" if platform.system() == "Windows" else "bin") / "pip"
|
|
||||||
|
|
||||||
print("📦 Installing Python requirements...")
|
|
||||||
subprocess.run([str(pip_path), "install", "--upgrade", "pip"], check=True)
|
|
||||||
subprocess.run([str(pip_path), "install", "-r", "requirements.txt"], check=True)
|
|
||||||
print("✓ Python requirements installed")
|
|
||||||
|
|
||||||
|
|
||||||
def create_directories():
|
|
||||||
"""Create necessary directories"""
|
|
||||||
directories = [
|
|
||||||
"downloads",
|
|
||||||
"resumenes_docx",
|
|
||||||
"logs",
|
|
||||||
"processed"
|
|
||||||
]
|
|
||||||
|
|
||||||
print("\n📁 Creating directories...")
|
|
||||||
for directory in directories:
|
|
||||||
Path(directory).mkdir(exist_ok=True)
|
|
||||||
print(f" ✓ {directory}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def create_env_file():
|
|
||||||
"""Create .env file if it doesn't exist"""
|
|
||||||
env_path = Path(".env")
|
|
||||||
example_path = Path(".env.example")
|
|
||||||
|
|
||||||
if env_path.exists():
|
|
||||||
print("✓ .env file already exists")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not example_path.exists():
|
|
||||||
print("⚠ .env.example not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("\n📝 Creating .env file from template...")
|
|
||||||
print(" Please edit .env file and add your API keys")
|
|
||||||
|
|
||||||
with open(example_path, "r") as src:
|
|
||||||
content = src.read()
|
|
||||||
|
|
||||||
with open(env_path, "w") as dst:
|
|
||||||
dst.write(content)
|
|
||||||
|
|
||||||
print("✓ .env file created from .env.example")
|
|
||||||
print(" ⚠ Please edit .env and add your API keys!")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main setup function"""
|
|
||||||
print("=" * 60)
|
|
||||||
print("CBCFacil Setup Script")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Check Python version
|
|
||||||
check_python_version()
|
|
||||||
|
|
||||||
# Check system dependencies
|
|
||||||
check_system_dependencies()
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
venv_path = create_virtual_environment()
|
|
||||||
|
|
||||||
# Install requirements
|
|
||||||
install_requirements(venv_path)
|
|
||||||
|
|
||||||
# Create directories
|
|
||||||
create_directories()
|
|
||||||
|
|
||||||
# Create .env file
|
|
||||||
create_env_file()
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("✓ Setup complete!")
|
|
||||||
print("=" * 60)
|
|
||||||
print("\nNext steps:")
|
|
||||||
print(" 1. Edit .env file and add your API keys")
|
|
||||||
print(" 2. Run: source venv/bin/activate (Linux/macOS)")
|
|
||||||
print(" or venv\\Scripts\\activate (Windows)")
|
|
||||||
print(" 3. Run: python main_refactored.py")
|
|
||||||
print("\nFor dashboard only:")
|
|
||||||
print(" python -c \"from api.routes import create_app; app = create_app(); app.run(port=5000)\"")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""
|
|
||||||
Storage package for CBCFacil
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .processed_registry import ProcessedRegistry
|
|
||||||
|
|
||||||
__all__ = ['ProcessedRegistry']
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"""
|
|
||||||
Processed files registry - Optimized version with bloom filter and better caching
|
|
||||||
"""
|
|
||||||
import fcntl
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Set, Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
|
|
||||||
class BloomFilter:
|
|
||||||
"""Simple Bloom Filter for fast membership testing"""
|
|
||||||
|
|
||||||
def __init__(self, size: int = 10000, hash_count: int = 3):
|
|
||||||
self.size = size
|
|
||||||
self.hash_count = hash_count
|
|
||||||
self.bit_array = [0] * size
|
|
||||||
|
|
||||||
def _hashes(self, item: str) -> list[int]:
|
|
||||||
"""Generate hash positions for item"""
|
|
||||||
import hashlib
|
|
||||||
digest = hashlib.md5(item.encode()).digest()
|
|
||||||
return [
|
|
||||||
int.from_bytes(digest[i:i+4], 'big') % self.size
|
|
||||||
for i in range(0, min(self.hash_count * 4, len(digest)), 4)
|
|
||||||
]
|
|
||||||
|
|
||||||
def add(self, item: str) -> None:
|
|
||||||
for pos in self._hashes(item):
|
|
||||||
self.bit_array[pos] = 1
|
|
||||||
|
|
||||||
def might_contain(self, item: str) -> bool:
|
|
||||||
return all(self.bit_array[pos] for pos in self._hashes(item))
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedRegistry:
|
|
||||||
"""Registry for tracking processed files with caching and file locking"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._cache: Set[str] = set()
|
|
||||||
self._cache_time: Optional[float] = None
|
|
||||||
self._cache_ttl = 300 # 5 minutos (antes era 60s)
|
|
||||||
self._initialized = False
|
|
||||||
self._bloom_filter = BloomFilter(size=10000, hash_count=3)
|
|
||||||
self._write_lock = False # Write batching
|
|
||||||
|
|
||||||
def initialize(self) -> None:
|
|
||||||
"""Initialize the registry"""
|
|
||||||
self.load()
|
|
||||||
self._initialized = True
|
|
||||||
self.logger.info(f"Processed registry initialized ({self.count()} files)")
|
|
||||||
|
|
||||||
def load(self) -> Set[str]:
|
|
||||||
"""Load processed files from disk with caching"""
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
# Return cached data if still valid
|
|
||||||
if self._cache and self._cache_time:
|
|
||||||
age = now - self._cache_time
|
|
||||||
if age < self._cache_ttl:
|
|
||||||
return self._cache # Return reference, not copy for read-only
|
|
||||||
|
|
||||||
processed = set()
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if registry_path.exists():
|
|
||||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
|
||||||
for raw_line in f:
|
|
||||||
line = raw_line.strip()
|
|
||||||
if line and not line.startswith('#'):
|
|
||||||
processed.add(line)
|
|
||||||
# Add basename for both path and basename lookups
|
|
||||||
base_name = Path(line).name
|
|
||||||
processed.add(base_name)
|
|
||||||
# Update bloom filter
|
|
||||||
self._bloom_filter.add(line)
|
|
||||||
self._bloom_filter.add(base_name)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error reading processed files registry: {e}")
|
|
||||||
|
|
||||||
self._cache = processed
|
|
||||||
self._cache_time = now
|
|
||||||
return processed # Return reference, not copy
|
|
||||||
|
|
||||||
def save(self, file_path: str) -> None:
|
|
||||||
"""Add file to processed registry with file locking"""
|
|
||||||
if not file_path:
|
|
||||||
return
|
|
||||||
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
if file_path in self._cache:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Append to file
|
|
||||||
with open(registry_path, 'a', encoding='utf-8') as f:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
||||||
try:
|
|
||||||
f.write(file_path + "\n")
|
|
||||||
# Update in-memory structures
|
|
||||||
self._cache.add(file_path)
|
|
||||||
self._bloom_filter.add(file_path)
|
|
||||||
self._cache_time = time.time()
|
|
||||||
self.logger.debug(f"Added {file_path} to processed registry")
|
|
||||||
finally:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error saving to processed files registry: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def is_processed(self, file_path: str) -> bool:
|
|
||||||
"""Check if file has been processed - O(1) with bloom filter"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
|
|
||||||
# Fast bloom filter check first
|
|
||||||
if not self._bloom_filter.might_contain(file_path):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check cache (O(1) for both full path and basename)
|
|
||||||
if file_path in self._cache:
|
|
||||||
return True
|
|
||||||
basename = Path(file_path).name
|
|
||||||
if basename in self._cache:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def save_batch(self, file_paths: list[str]) -> int:
|
|
||||||
"""Add multiple files to registry efficiently"""
|
|
||||||
saved_count = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(registry_path, 'a', encoding='utf-8') as f:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
||||||
try:
|
|
||||||
lines_to_write = []
|
|
||||||
for file_path in file_paths:
|
|
||||||
if file_path and file_path not in self._cache:
|
|
||||||
lines_to_write.append(file_path + "\n")
|
|
||||||
self._cache.add(file_path)
|
|
||||||
self._bloom_filter.add(file_path)
|
|
||||||
saved_count += 1
|
|
||||||
|
|
||||||
if lines_to_write:
|
|
||||||
f.writelines(lines_to_write)
|
|
||||||
self._cache_time = time.time()
|
|
||||||
self.logger.debug(f"Added {saved_count} files to processed registry")
|
|
||||||
finally:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error saving batch to processed files registry: {e}")
|
|
||||||
|
|
||||||
return saved_count
|
|
||||||
|
|
||||||
def remove(self, file_path: str) -> bool:
|
|
||||||
"""Remove file from processed registry"""
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not registry_path.exists():
|
|
||||||
return False
|
|
||||||
lines_to_keep = []
|
|
||||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
|
||||||
for line in f:
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped != file_path and Path(stripped).name != Path(file_path).name:
|
|
||||||
lines_to_keep.append(line)
|
|
||||||
with open(registry_path, 'w', encoding='utf-8') as f:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
||||||
try:
|
|
||||||
f.writelines(lines_to_keep)
|
|
||||||
self._cache.discard(file_path)
|
|
||||||
self._cache.discard(Path(file_path).name)
|
|
||||||
# Rebuild bloom filter
|
|
||||||
self._bloom_filter = BloomFilter(size=10000, hash_count=3)
|
|
||||||
for item in self._cache:
|
|
||||||
self._bloom_filter.add(item)
|
|
||||||
finally:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error removing from processed files registry: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Clear the entire registry"""
|
|
||||||
registry_path = settings.processed_files_path
|
|
||||||
try:
|
|
||||||
if registry_path.exists():
|
|
||||||
registry_path.unlink()
|
|
||||||
self._cache.clear()
|
|
||||||
self._cache_time = None
|
|
||||||
self._bloom_filter = BloomFilter(size=10000, hash_count=3)
|
|
||||||
self.logger.info("Processed files registry cleared")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error clearing processed files registry: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_all(self) -> Set[str]:
|
|
||||||
"""Get all processed files"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return self._cache.copy()
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
"""Get count of processed files"""
|
|
||||||
if not self._initialized:
|
|
||||||
self.initialize()
|
|
||||||
return len(self._cache)
|
|
||||||
|
|
||||||
def get_stats(self) -> dict:
|
|
||||||
"""Get registry statistics"""
|
|
||||||
return {
|
|
||||||
"total_files": len(self._cache),
|
|
||||||
"cache_age_seconds": time.time() - self._cache_time if self._cache_time else 0,
|
|
||||||
"cache_ttl_seconds": self._cache_ttl,
|
|
||||||
"bloom_filter_size": self._bloom_filter.size,
|
|
||||||
"initialized": self._initialized
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
processed_registry = ProcessedRegistry()
|
|
||||||
1586
templates/index.html
1586
templates/index.html
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
"""
|
|
||||||
Test package for CBCFacil
|
|
||||||
"""
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"""
|
|
||||||
Pytest configuration for CBCFacil tests
|
|
||||||
Ensures proper Python path for module imports
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add project root to path
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
# Set environment variables for testing
|
|
||||||
os.environ.setdefault('LOCAL_STATE_DIR', str(PROJECT_ROOT / 'state'))
|
|
||||||
os.environ.setdefault('LOCAL_DOWNLOADS_PATH', str(PROJECT_ROOT / 'downloads'))
|
|
||||||
|
|
||||||
# Disable external service connections during tests
|
|
||||||
os.environ.setdefault('NEXTCLOUD_URL', '')
|
|
||||||
os.environ.setdefault('ANTHROPIC_AUTH_TOKEN', '')
|
|
||||||
os.environ.setdefault('GEMINI_API_KEY', '')
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script de verificación para mejoras de la Fase 3
|
|
||||||
Verifica que todas las mejoras están correctamente implementadas
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def check_file_exists(filepath, description):
|
|
||||||
"""Verificar que un archivo existe"""
|
|
||||||
if Path(filepath).exists():
|
|
||||||
print(f"✅ {description}: {filepath}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"❌ {description}: {filepath} NO ENCONTRADO")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_function_in_file(filepath, function_name, description):
|
|
||||||
"""Verificar que una función existe en un archivo"""
|
|
||||||
try:
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
if function_name in content:
|
|
||||||
print(f"✅ {description}: Encontrado '{function_name}'")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"❌ {description}: NO encontrado '{function_name}'")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error leyendo {filepath}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_class_in_file(filepath, class_name, description):
|
|
||||||
"""Verificar que una clase existe en un archivo"""
|
|
||||||
return check_function_in_file(filepath, f"class {class_name}", description)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 70)
|
|
||||||
print("🔍 VERIFICACIÓN DE MEJORAS FASE 3 - CBCFacil")
|
|
||||||
print("=" * 70)
|
|
||||||
print()
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# 1. Verificar archivos modificados/creados
|
|
||||||
print("📁 ARCHIVOS:")
|
|
||||||
print("-" * 70)
|
|
||||||
results.append(check_file_exists("main.py", "main.py modificado"))
|
|
||||||
results.append(check_file_exists("config/settings.py", "config/settings.py modificado"))
|
|
||||||
results.append(check_file_exists("core/health_check.py", "core/health_check.py creado"))
|
|
||||||
results.append(check_file_exists("IMPROVEMENTS_LOG.md", "IMPROVEMENTS_LOG.md creado"))
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 2. Verificar mejoras en main.py
|
|
||||||
print("🔧 MEJORAS EN main.py:")
|
|
||||||
print("-" * 70)
|
|
||||||
results.append(check_function_in_file("main.py", "logger.exception", "logger.exception() implementado"))
|
|
||||||
results.append(check_function_in_file("main.py", "class JSONFormatter", "JSONFormatter implementado"))
|
|
||||||
results.append(check_function_in_file("main.py", "def validate_configuration", "validate_configuration() implementado"))
|
|
||||||
results.append(check_function_in_file("main.py", "def check_service_health", "check_service_health() implementado"))
|
|
||||||
results.append(check_function_in_file("main.py", "def send_error_notification", "send_error_notification() implementado"))
|
|
||||||
results.append(check_function_in_file("main.py", "def setup_logging", "setup_logging() implementado"))
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 3. Verificar mejoras en config/settings.py
|
|
||||||
print("⚙️ MEJORAS EN config/settings.py:")
|
|
||||||
print("-" * 70)
|
|
||||||
results.append(check_function_in_file("config/settings.py", "class ConfigurationError", "ConfigurationError definido"))
|
|
||||||
results.append(check_function_in_file("config/settings.py", "def nextcloud_url", "Propiedad nextcloud_url con validación"))
|
|
||||||
results.append(check_function_in_file("config/settings.py", "def valid_webdav_config", "Propiedad valid_webdav_config"))
|
|
||||||
results.append(check_function_in_file("config/settings.py", "def telegram_configured", "Propiedad telegram_configured"))
|
|
||||||
results.append(check_function_in_file("config/settings.py", "def has_gpu_support", "Propiedad has_gpu_support"))
|
|
||||||
results.append(check_function_in_file("config/settings.py", "def config_summary", "Propiedad config_summary"))
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 4. Verificar core/health_check.py
|
|
||||||
print("❤️ HEALTH CHECKS:")
|
|
||||||
print("-" * 70)
|
|
||||||
results.append(check_function_in_file("core/health_check.py", "class HealthChecker", "Clase HealthChecker"))
|
|
||||||
results.append(check_function_in_file("core/health_check.py", "def check_webdav_connection", "check_webdav_connection()"))
|
|
||||||
results.append(check_function_in_file("core/health_check.py", "def check_ai_providers", "check_ai_providers()"))
|
|
||||||
results.append(check_function_in_file("core/health_check.py", "def check_vram_manager", "check_vram_manager()"))
|
|
||||||
results.append(check_function_in_file("core/health_check.py", "def check_disk_space", "check_disk_space()"))
|
|
||||||
results.append(check_function_in_file("core/health_check.py", "def run_full_health_check", "run_full_health_check()"))
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Resumen
|
|
||||||
print("=" * 70)
|
|
||||||
print("📊 RESUMEN:")
|
|
||||||
print("=" * 70)
|
|
||||||
passed = sum(results)
|
|
||||||
total = len(results)
|
|
||||||
percentage = (passed / total) * 100
|
|
||||||
|
|
||||||
print(f"Verificaciones pasadas: {passed}/{total} ({percentage:.1f}%)")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if percentage == 100:
|
|
||||||
print("🎉 ¡TODAS LAS MEJORAS ESTÁN CORRECTAMENTE IMPLEMENTADAS!")
|
|
||||||
print()
|
|
||||||
print("Puedes probar:")
|
|
||||||
print(" python main.py health")
|
|
||||||
print()
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print("⚠️ Algunas verificaciones fallaron")
|
|
||||||
print()
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
4
watchers/__init__.py
Normal file
4
watchers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Export de watchers."""
|
||||||
|
from .folder_watcher import FolderWatcher, RemoteFolderWatcher
|
||||||
|
|
||||||
|
__all__ = ["FolderWatcher", "RemoteFolderWatcher"]
|
||||||
218
watchers/folder_watcher.py
Normal file
218
watchers/folder_watcher.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Watcher de carpeta local.
|
||||||
|
Monitorea una carpeta por archivos nuevos y los procesa.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services import WebDAVService
|
||||||
|
|
||||||
|
|
||||||
|
class FileHandler(FileSystemEventHandler):
|
||||||
|
"""Manejador de eventos del sistema de archivos."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
on_new_file: Callable[[Path], None],
|
||||||
|
logger: Optional[logging.Logger] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.on_new_file = on_new_file
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def on_created(self, event: FileCreatedEvent) -> None:
|
||||||
|
"""Se llama cuando se crea un nuevo archivo."""
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = Path(event.src_path)
|
||||||
|
self.logger.info(f"New file detected: {file_path}")
|
||||||
|
|
||||||
|
# Ignorar archivos temporales
|
||||||
|
if file_path.suffix in [".tmp", ".part", ".crdownload"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ignorar archivos ocultos
|
||||||
|
if file_path.name.startswith("."):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Esperar a que el archivo esté listo
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.on_new_file(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error processing file {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class FolderWatcher:
|
||||||
|
"""Monitor de carpeta local."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
watch_path: Optional[Path] = None,
|
||||||
|
on_new_file: Optional[Callable[[Path], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.watch_path = watch_path or settings.DOWNLOADS_DIR
|
||||||
|
self.on_new_file_callback = on_new_file
|
||||||
|
self._observer: Optional[Observer] = None
|
||||||
|
self._running = False
|
||||||
|
self._processed_files: set[str] = set()
|
||||||
|
|
||||||
|
# Asegurar que la carpeta existe
|
||||||
|
self.watch_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def set_callback(self, callback: Callable[[Path], None]) -> None:
|
||||||
|
"""Establece el callback para nuevos archivos."""
|
||||||
|
self.on_new_file_callback = callback
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Inicia el watcher."""
|
||||||
|
if self._running:
|
||||||
|
self.logger.warning("Watcher already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info(f"Starting folder watcher on: {self.watch_path}")
|
||||||
|
|
||||||
|
event_handler = FileHandler(
|
||||||
|
on_new_file=self._handle_new_file,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._observer = Observer()
|
||||||
|
self._observer.schedule(event_handler, str(self.watch_path), recursive=False)
|
||||||
|
self._observer.start()
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
self.logger.info("Folder watcher started")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Detiene el watcher."""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info("Stopping folder watcher")
|
||||||
|
if self._observer:
|
||||||
|
self._observer.stop()
|
||||||
|
self._observer.join()
|
||||||
|
self._observer = None
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.logger.info("Folder watcher stopped")
|
||||||
|
|
||||||
|
def _handle_new_file(self, file_path: Path) -> None:
|
||||||
|
"""Maneja un nuevo archivo detectado."""
|
||||||
|
file_key = str(file_path)
|
||||||
|
|
||||||
|
# Evitar procesar el mismo archivo dos veces
|
||||||
|
if file_key in self._processed_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._processed_files.add(file_key)
|
||||||
|
self.logger.info(f"Processing new file: {file_path}")
|
||||||
|
|
||||||
|
if self.on_new_file_callback:
|
||||||
|
self.on_new_file_callback(file_path)
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Obtiene el estado del watcher."""
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"watch_path": str(self.watch_path),
|
||||||
|
"processed_files_count": len(self._processed_files),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteFolderWatcher:
|
||||||
|
"""Watcher que descarga archivos desde Nextcloud."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
webdav_service: WebDAVService,
|
||||||
|
local_path: Optional[Path] = None,
|
||||||
|
remote_path: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.webdav = webdav_service
|
||||||
|
self.local_path = local_path or settings.DOWNLOADS_DIR
|
||||||
|
self.remote_path = remote_path or settings.WATCHED_REMOTE_PATH
|
||||||
|
self._running = False
|
||||||
|
self._last_checked_files: set[str] = set()
|
||||||
|
self._on_download: Optional[Callable[[Path], None]] = None
|
||||||
|
|
||||||
|
# Asegurar que la carpeta local existe
|
||||||
|
self.local_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def set_callback(self, callback: Callable[[Path], None]) -> None:
|
||||||
|
"""Establece el callback para archivos descargados."""
|
||||||
|
self._on_download = callback
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Inicia el polling de archivos remotos."""
|
||||||
|
if self._running:
|
||||||
|
self.logger.warning("Remote watcher already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self.logger.info(f"Starting remote folder watcher: {self.remote_path} -> {self.local_path}")
|
||||||
|
|
||||||
|
# Primer escaneo
|
||||||
|
self._check_for_new_files()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Detiene el watcher."""
|
||||||
|
self._running = False
|
||||||
|
self.logger.info("Remote folder watcher stopped")
|
||||||
|
|
||||||
|
def check_now(self) -> None:
|
||||||
|
"""Fuerza una verificación inmediata."""
|
||||||
|
self._check_for_new_files()
|
||||||
|
|
||||||
|
def _check_for_new_files(self) -> None:
|
||||||
|
"""Verifica si hay nuevos archivos en Nextcloud."""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = self.webdav.list_files(self.remote_path)
|
||||||
|
current_files = set(files) - self._last_checked_files
|
||||||
|
|
||||||
|
if current_files:
|
||||||
|
self.logger.info(f"Found {len(current_files)} new files")
|
||||||
|
|
||||||
|
for filename in current_files:
|
||||||
|
if filename.strip(): # Ignorar nombres vacíos
|
||||||
|
self._download_file(filename)
|
||||||
|
|
||||||
|
self._last_checked_files = set(files)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error checking remote files: {e}")
|
||||||
|
|
||||||
|
def _download_file(self, filename: str) -> None:
|
||||||
|
"""Descarga un archivo individual."""
|
||||||
|
remote_path = f"{self.remote_path}/{filename}"
|
||||||
|
local_path = self.local_path / filename
|
||||||
|
|
||||||
|
self.logger.info(f"Downloading: {remote_path}")
|
||||||
|
|
||||||
|
if self.webdav.download_file(remote_path, local_path):
|
||||||
|
if self._on_download:
|
||||||
|
self._on_download(local_path)
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Failed to download: {filename}")
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Obtiene el estado del watcher."""
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"remote_path": self.remote_path,
|
||||||
|
"local_path": str(self.local_path),
|
||||||
|
"last_checked_files": len(self._last_checked_files),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user