Compare commits
14 Commits
e6a01d08d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7726365d7 | ||
|
|
d50772d962 | ||
|
|
d902203b59 | ||
|
|
1f6bfa771b | ||
|
|
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_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)
|
||||
# =============================================================================
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -9,6 +9,7 @@ __pycache__/
|
||||
|
||||
# Application-generated data
|
||||
downloads/
|
||||
transcriptions/
|
||||
resumenes/
|
||||
resumenes_docx/
|
||||
processed_files.txt
|
||||
@@ -71,3 +72,17 @@ old/
|
||||
imperio/
|
||||
check_models.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 @@
|
||||
"""
|
||||
API package for CBCFacil
|
||||
"""
|
||||
"""Export de API."""
|
||||
from .routes import api_bp, init_api, process_manager
|
||||
|
||||
from .routes import create_app
|
||||
|
||||
__all__ = ['create_app']
|
||||
__all__ = ["api_bp", "init_api", "process_manager"]
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from flask import Flask, render_template, request, jsonify, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from flask.typing import ResponseValue
|
||||
|
||||
from config import settings
|
||||
from storage import processed_registry
|
||||
from services.webdav_service import webdav_service
|
||||
from services import vram_manager
|
||||
from core.process_manager import ProcessManager as CoreProcessManager
|
||||
from services import WebDAVService
|
||||
from watchers import RemoteFolderWatcher
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Create and configure Flask application"""
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
# Logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure app
|
||||
app.config['SECRET_KEY'] = settings.DASHBOARD_SECRET_KEY or os.urandom(24)
|
||||
app.config['DOWNLOADS_FOLDER'] = str(settings.LOCAL_DOWNLOADS_PATH)
|
||||
# Blueprint
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Dashboard home page"""
|
||||
return render_template('index.html')
|
||||
# Instancias globales (se inicializan en main.py)
|
||||
webdav_service: WebDAVService = None
|
||||
remote_watcher: RemoteFolderWatcher = None
|
||||
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 reprocess_file():
|
||||
"""Reprocess a file"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_path = data.get('path')
|
||||
source = data.get('source', 'local')
|
||||
def init_api(
|
||||
pm: CoreProcessManager,
|
||||
wd_service: Optional[WebDAVService] = None,
|
||||
watcher: Optional[RemoteFolderWatcher] = None,
|
||||
) -> None:
|
||||
"""Inicializa las referencias a los servicios."""
|
||||
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
|
||||
# This would trigger the main processing loop
|
||||
class LocalProcessManager:
|
||||
"""
|
||||
Gestor local de archivos para la API.
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f"Archivo {Path(file_path).name} enviado a reprocesamiento"
|
||||
})
|
||||
Provee métodos para obtener detalles de archivos y transcripciones
|
||||
desde el sistema de archivos local.
|
||||
"""
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error reprocessing file: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f"Error: {str(e)}"
|
||||
}), 500
|
||||
def __init__(self) -> None:
|
||||
self._transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
|
||||
@app.route('/api/mark-unprocessed', methods=['POST'])
|
||||
def mark_unprocessed():
|
||||
"""Mark file as unprocessed"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_path = data.get('path')
|
||||
def get_all_files_detailed(self) -> list[dict[str, Any]]:
|
||||
"""Obtiene información detallada de todos los archivos."""
|
||||
files_data = []
|
||||
|
||||
if not file_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': "Path del archivo es requerido"
|
||||
}), 400
|
||||
if self._transcriptions_dir.exists():
|
||||
for f in self._transcriptions_dir.iterdir():
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
file_info = self._get_file_detail(f)
|
||||
files_data.append(file_info)
|
||||
|
||||
success = processed_registry.remove(file_path)
|
||||
return sorted(files_data, key=lambda x: x["modified"], reverse=True)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': "Archivo marcado como no procesado"
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': "No se pudo marcar como no procesado"
|
||||
}), 500
|
||||
def _get_file_detail(self, file_path: Path) -> dict[str, Any]:
|
||||
"""Obtiene información detallada de un archivo."""
|
||||
filename = file_path.name
|
||||
stat = file_path.stat()
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error marking unprocessed: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f"Error: {str(e)}"
|
||||
}), 500
|
||||
# Buscar transcripción si existe archivo .txt
|
||||
transcription_text = None
|
||||
if file_path.suffix != ".txt":
|
||||
txt_path = file_path.with_suffix(".txt")
|
||||
if txt_path.exists():
|
||||
transcription_text = txt_path.read_text(encoding="utf-8")
|
||||
|
||||
@app.route('/api/refresh')
|
||||
def refresh_files():
|
||||
"""Refresh file list"""
|
||||
try:
|
||||
processed_registry.load()
|
||||
files = get_audio_files()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': "Lista de archivos actualizada",
|
||||
'files': files
|
||||
})
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error refreshing files: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f"Error: {str(e)}"
|
||||
}), 500
|
||||
return {
|
||||
"name": filename,
|
||||
"path": str(file_path),
|
||||
"size": stat.st_size,
|
||||
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
"extension": file_path.suffix.lower(),
|
||||
"status": "transcribed" if transcription_text else "pending",
|
||||
"transcription": transcription_text,
|
||||
"transcription_length": len(transcription_text or ""),
|
||||
}
|
||||
|
||||
@app.route('/downloads/<path:filename>')
|
||||
def download_file(filename):
|
||||
"""Download file"""
|
||||
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
|
||||
def get_file_detail(self, filename: str) -> Optional[dict[str, Any]]:
|
||||
"""Obtiene información detallada de un archivo específico."""
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
|
||||
# Try downloads directory
|
||||
downloads_path = settings.LOCAL_DOWNLOADS_PATH / filename
|
||||
if downloads_path.exists():
|
||||
return send_from_directory(str(settings.LOCAL_DOWNLOADS_PATH), filename)
|
||||
# Buscar archivo con cualquier extensión que coincida con el nombre base
|
||||
name_without_ext = Path(filename).stem
|
||||
for f in transcriptions_dir.iterdir():
|
||||
if f.stem == name_without_ext:
|
||||
return self._get_file_detail(f)
|
||||
|
||||
# Try resumenes_docx directory
|
||||
docx_path = settings.LOCAL_DOCX / filename
|
||||
if docx_path.exists():
|
||||
return send_from_directory(str(settings.LOCAL_DOCX), filename)
|
||||
return None
|
||||
|
||||
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:
|
||||
app.logger.error(f"Error downloading file: {e}")
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
gpu_info = vram_manager.get_usage()
|
||||
|
||||
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
|
||||
# Buscar archivo .txt
|
||||
txt_path = transcriptions_dir / f"{name_without_ext}.txt"
|
||||
if txt_path.exists():
|
||||
text = txt_path.read_text(encoding="utf-8")
|
||||
return {
|
||||
"text": text,
|
||||
"created_at": datetime.fromtimestamp(txt_path.stat().st_mtime).isoformat(),
|
||||
"metadata": {},
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
return None
|
||||
|
||||
|
||||
def get_audio_files() -> List[Dict[str, Any]]:
|
||||
"""Get list of audio files from WebDAV and local"""
|
||||
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'])
|
||||
# Instancia local para detalles de archivos
|
||||
local_pm = LocalProcessManager()
|
||||
|
||||
|
||||
def get_available_formats(audio_filename: str) -> Dict[str, bool]:
|
||||
"""Check which output formats are available for an audio file"""
|
||||
base_name = Path(audio_filename).stem
|
||||
@api_bp.route("/health", methods=["GET"])
|
||||
def health_check() -> ResponseValue:
|
||||
"""Health check endpoint."""
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
formats = {
|
||||
'txt': False,
|
||||
'md': False,
|
||||
'pdf': False,
|
||||
'docx': False
|
||||
|
||||
@api_bp.route("/status", methods=["GET"])
|
||||
def status() -> ResponseValue:
|
||||
"""Estado del sistema."""
|
||||
status_data = {
|
||||
"webdav_configured": settings.has_webdav_config,
|
||||
"webdav_connected": False,
|
||||
"watcher": None,
|
||||
}
|
||||
|
||||
directories_to_check = [
|
||||
settings.LOCAL_DOWNLOADS_PATH,
|
||||
settings.LOCAL_DOCX
|
||||
]
|
||||
# Verificar conexión WebDAV
|
||||
if settings.has_webdav_config and webdav_service:
|
||||
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:
|
||||
if not directory.exists():
|
||||
continue
|
||||
# Estado del watcher
|
||||
if remote_watcher:
|
||||
status_data["watcher"] = remote_watcher.get_status()
|
||||
|
||||
for ext in formats.keys():
|
||||
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
|
||||
return jsonify(status_data), 200
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format size in human-readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} TB"
|
||||
@api_bp.route("/files", methods=["GET"])
|
||||
def list_files() -> ResponseValue:
|
||||
"""Lista archivos en la carpeta local."""
|
||||
try:
|
||||
files = []
|
||||
downloads_dir = settings.DOWNLOADS_DIR
|
||||
|
||||
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 @@
|
||||
"""
|
||||
Configuration package for CBCFacil
|
||||
"""
|
||||
"""Export de configuración."""
|
||||
from .settings import settings, Settings
|
||||
|
||||
from .settings import settings
|
||||
from .validators import validate_environment
|
||||
|
||||
__all__ = ['settings', 'validate_environment']
|
||||
__all__ = ["settings", "Settings"]
|
||||
|
||||
@@ -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
|
||||
from pathlib import Path
|
||||
from typing import Optional, Set, Union
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when configuration is invalid"""
|
||||
pass
|
||||
# Cargar variables de entorno
|
||||
load_dotenv()
|
||||
|
||||
|
||||
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_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: 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 (opcional)
|
||||
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"))
|
||||
# AI Providers (opcional)
|
||||
GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
|
||||
DEEPINFRA_API_KEY: Optional[str] = os.getenv("DEEPINFRA_API_KEY")
|
||||
ANTHROPIC_AUTH_TOKEN: Optional[str] = os.getenv("ANTHROPIC_AUTH_TOKEN")
|
||||
ANTHROPIC_BASE_URL: Optional[str] = os.getenv("ANTHROPIC_BASE_URL")
|
||||
ANTHROPIC_MODEL: str = os.getenv("ANTHROPIC_MODEL", "glm-4.7")
|
||||
|
||||
# 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")
|
||||
|
||||
# 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")
|
||||
# Notion (opcional)
|
||||
NOTION_API: Optional[str] = os.getenv("NOTION_API")
|
||||
NOTION_DATABASE_ID: Optional[str] = os.getenv("NOTION_DATABASE_ID")
|
||||
|
||||
# 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_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
|
||||
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"))
|
||||
|
||||
# ========================================================================
|
||||
# PROPERTIES WITH VALIDATION
|
||||
# ========================================================================
|
||||
|
||||
@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])
|
||||
"""Verifica si hay configuración de WebDAV."""
|
||||
return bool(self.NEXTCLOUD_URL and self.NEXTCLOUD_USER and 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")))
|
||||
|
||||
@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"""
|
||||
def has_telegram_config(self) -> bool:
|
||||
"""Verifica si hay configuración de Telegram."""
|
||||
return bool(self.TELEGRAM_TOKEN and self.TELEGRAM_CHAT_ID)
|
||||
|
||||
@property
|
||||
def has_gpu_support(self) -> bool:
|
||||
"""Check if GPU support is available"""
|
||||
try:
|
||||
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
|
||||
}
|
||||
def is_production(self) -> bool:
|
||||
"""Verifica si está en modo producción."""
|
||||
return os.getenv("ENV", "development") == "production"
|
||||
|
||||
|
||||
# Create global settings instance
|
||||
# Instancia global de configuración
|
||||
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 package for CBCFacil
|
||||
"""
|
||||
"""Core module exports."""
|
||||
from .process_manager import ProcessManager, ProcessState
|
||||
from processors.audio_processor import AudioProcessingError
|
||||
|
||||
from .exceptions import (
|
||||
ProcessingError,
|
||||
WebDAVError,
|
||||
AIProcessingError,
|
||||
ConfigurationError,
|
||||
FileProcessingError
|
||||
)
|
||||
from .result import Result
|
||||
from .base_service import BaseService
|
||||
|
||||
__all__ = [
|
||||
'ProcessingError',
|
||||
'WebDAVError',
|
||||
'AIProcessingError',
|
||||
'ConfigurationError',
|
||||
'FileProcessingError',
|
||||
'Result',
|
||||
'BaseService'
|
||||
]
|
||||
__all__ = ["ProcessManager", "ProcessState", "AudioProcessingError"]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""
|
||||
Base service class for CBCFacil services
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BaseService(ABC):
|
||||
"""Base class for all services"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(f"{__name__}.{name}")
|
||||
|
||||
@abstractmethod
|
||||
def initialize(self) -> None:
|
||||
"""Initialize the service"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup service resources"""
|
||||
pass
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Perform health check"""
|
||||
return True
|
||||
|
||||
def __enter__(self):
|
||||
self.initialize()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.cleanup()
|
||||
@@ -1,38 +0,0 @@
|
||||
"""
|
||||
Custom exceptions for CBCFacil
|
||||
"""
|
||||
|
||||
|
||||
class ProcessingError(Exception):
|
||||
"""Base exception for all processing errors"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(ProcessingError):
|
||||
"""Raised when configuration is invalid"""
|
||||
pass
|
||||
|
||||
|
||||
class WebDAVError(ProcessingError):
|
||||
"""Raised when WebDAV operations fail"""
|
||||
pass
|
||||
|
||||
|
||||
class AIProcessingError(ProcessingError):
|
||||
"""Raised when AI processing fails"""
|
||||
pass
|
||||
|
||||
|
||||
class FileProcessingError(ProcessingError):
|
||||
"""Raised when file processing fails"""
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(ProcessingError):
|
||||
"""Raised when authentication fails"""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ProcessingError):
|
||||
"""Raised when input validation fails"""
|
||||
pass
|
||||
@@ -1,355 +0,0 @@
|
||||
"""
|
||||
Health check endpoint for CBCFacil service monitoring
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
"""Comprehensive health check for all service dependencies"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def check_webdav_connection(self) -> Dict[str, Any]:
|
||||
"""Check WebDAV service connectivity"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "webdav",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from services.webdav_service import webdav_service
|
||||
|
||||
if not settings.has_webdav_config:
|
||||
result["status"] = "not_configured"
|
||||
result["message"] = "WebDAV credentials not configured"
|
||||
return result
|
||||
|
||||
# Test connection with a simple list operation
|
||||
webdav_service.list(".")
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["message"] = "WebDAV connection successful"
|
||||
result["endpoint"] = settings.NEXTCLOUD_URL
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "unhealthy"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"WebDAV health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_ai_providers(self) -> Dict[str, Any]:
|
||||
"""Check AI provider configurations"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "ai_providers",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"providers": {}
|
||||
}
|
||||
|
||||
try:
|
||||
# Check ZAI
|
||||
if settings.ZAI_AUTH_TOKEN:
|
||||
result["providers"]["zai"] = {
|
||||
"configured": True,
|
||||
"status": "unknown"
|
||||
}
|
||||
else:
|
||||
result["providers"]["zai"] = {
|
||||
"configured": False,
|
||||
"status": "not_configured"
|
||||
}
|
||||
|
||||
# Check Gemini
|
||||
if settings.GEMINI_API_KEY:
|
||||
result["providers"]["gemini"] = {
|
||||
"configured": True,
|
||||
"status": "unknown"
|
||||
}
|
||||
else:
|
||||
result["providers"]["gemini"] = {
|
||||
"configured": False,
|
||||
"status": "not_configured"
|
||||
}
|
||||
|
||||
# Check CLI providers
|
||||
if settings.CLAUDE_CLI_PATH:
|
||||
claude_path = Path(settings.CLAUDE_CLI_PATH)
|
||||
result["providers"]["claude_cli"] = {
|
||||
"configured": True,
|
||||
"path_exists": claude_path.exists(),
|
||||
"status": "available" if claude_path.exists() else "path_invalid"
|
||||
}
|
||||
|
||||
if settings.GEMINI_CLI_PATH:
|
||||
gemini_path = Path(settings.GEMINI_CLI_PATH)
|
||||
result["providers"]["gemini_cli"] = {
|
||||
"configured": True,
|
||||
"path_exists": gemini_path.exists(),
|
||||
"status": "available" if gemini_path.exists() else "path_invalid"
|
||||
}
|
||||
|
||||
# Overall status
|
||||
if settings.has_ai_config:
|
||||
result["status"] = "healthy"
|
||||
result["message"] = "At least one AI provider configured"
|
||||
else:
|
||||
result["status"] = "not_configured"
|
||||
result["message"] = "No AI providers configured"
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"AI providers health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_vram_manager(self) -> Dict[str, Any]:
|
||||
"""Check VRAM manager status"""
|
||||
result = {
|
||||
"service": "vram_manager",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from services.vram_manager import vram_manager
|
||||
|
||||
vram_info = vram_manager.get_vram_info()
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["vram_info"] = {
|
||||
"total_gb": round(vram_info.get("total", 0) / (1024**3), 2),
|
||||
"free_gb": round(vram_info.get("free", 0) / (1024**3), 2),
|
||||
"allocated_gb": round(vram_info.get("allocated", 0) / (1024**3), 2)
|
||||
}
|
||||
result["cuda_available"] = vram_info.get("cuda_available", False)
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "unavailable"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"VRAM manager health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_telegram_service(self) -> Dict[str, Any]:
|
||||
"""Check Telegram service status"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "telegram",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from services.telegram_service import telegram_service
|
||||
|
||||
if telegram_service.is_configured:
|
||||
result["status"] = "healthy"
|
||||
result["message"] = "Telegram service configured"
|
||||
else:
|
||||
result["status"] = "not_configured"
|
||||
result["message"] = "Telegram credentials not configured"
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Telegram service health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_processed_registry(self) -> Dict[str, Any]:
|
||||
"""Check processed files registry"""
|
||||
result = {
|
||||
"service": "processed_registry",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from storage.processed_registry import processed_registry
|
||||
|
||||
# Try to load registry
|
||||
processed_registry.load()
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["registry_path"] = str(processed_registry.registry_path)
|
||||
|
||||
# Check if registry file is writable
|
||||
registry_file = Path(processed_registry.registry_path)
|
||||
if registry_file.exists():
|
||||
result["registry_exists"] = True
|
||||
result["registry_writable"] = registry_file.is_file() and os.access(registry_file, os.W_OK)
|
||||
else:
|
||||
result["registry_exists"] = False
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "unhealthy"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Processed registry health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_disk_space(self) -> Dict[str, Any]:
|
||||
"""Check available disk space"""
|
||||
result = {
|
||||
"service": "disk_space",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
import shutil
|
||||
|
||||
# Check main directory
|
||||
usage = shutil.disk_usage(Path(__file__).parent.parent)
|
||||
|
||||
total_gb = usage.total / (1024**3)
|
||||
free_gb = usage.free / (1024**3)
|
||||
used_percent = (usage.used / usage.total) * 100
|
||||
|
||||
result["status"] = "healthy"
|
||||
result["total_gb"] = round(total_gb, 2)
|
||||
result["free_gb"] = round(free_gb, 2)
|
||||
result["used_percent"] = round(used_percent, 2)
|
||||
|
||||
# Warning if low disk space
|
||||
if free_gb < 1: # Less than 1GB
|
||||
result["status"] = "warning"
|
||||
result["message"] = "Low disk space"
|
||||
elif free_gb < 5: # Less than 5GB
|
||||
result["status"] = "degraded"
|
||||
result["message"] = "Disk space running low"
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Disk space health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def check_configuration(self) -> Dict[str, Any]:
|
||||
"""Check configuration validity"""
|
||||
from config import settings
|
||||
|
||||
result = {
|
||||
"service": "configuration",
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
warnings = []
|
||||
|
||||
# Check for warnings
|
||||
if not settings.has_webdav_config:
|
||||
warnings.append("WebDAV not configured")
|
||||
|
||||
if not settings.has_ai_config:
|
||||
warnings.append("AI providers not configured")
|
||||
|
||||
if not settings.telegram_configured:
|
||||
warnings.append("Telegram not configured")
|
||||
|
||||
if settings.DASHBOARD_SECRET_KEY == "":
|
||||
warnings.append("Dashboard secret key not set")
|
||||
|
||||
if settings.DASHBOARD_SECRET_KEY == "dashboard-secret-key-change-in-production":
|
||||
warnings.append("Using default dashboard secret")
|
||||
|
||||
result["status"] = "healthy" if not warnings else "warning"
|
||||
result["warnings"] = warnings
|
||||
result["environment"] = settings.environment_type
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(e)
|
||||
self.logger.error(f"Configuration health check failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def run_full_health_check(self) -> Dict[str, Any]:
|
||||
"""Run all health checks and return comprehensive status"""
|
||||
checks = [
|
||||
("configuration", self.check_configuration),
|
||||
("webdav", self.check_webdav_connection),
|
||||
("ai_providers", self.check_ai_providers),
|
||||
("vram_manager", self.check_vram_manager),
|
||||
("telegram", self.check_telegram_service),
|
||||
("processed_registry", self.check_processed_registry),
|
||||
("disk_space", self.check_disk_space)
|
||||
]
|
||||
|
||||
results = {}
|
||||
overall_status = "healthy"
|
||||
|
||||
for check_name, check_func in checks:
|
||||
try:
|
||||
result = check_func()
|
||||
results[check_name] = result
|
||||
|
||||
# Track overall status
|
||||
if result["status"] in ["unhealthy", "error"]:
|
||||
overall_status = "unhealthy"
|
||||
elif result["status"] in ["warning", "degraded"] and overall_status == "healthy":
|
||||
overall_status = "warning"
|
||||
|
||||
except Exception as e:
|
||||
results[check_name] = {
|
||||
"service": check_name,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
overall_status = "unhealthy"
|
||||
self.logger.error(f"Health check {check_name} failed: {e}")
|
||||
|
||||
return {
|
||||
"overall_status": overall_status,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"checks": results,
|
||||
"summary": {
|
||||
"total_checks": len(checks),
|
||||
"healthy": sum(1 for r in results.values() if r["status"] == "healthy"),
|
||||
"warning": sum(1 for r in results.values() if r["status"] == "warning"),
|
||||
"unhealthy": sum(1 for r in results.values() if r["status"] == "unhealthy")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Convenience function for CLI usage
|
||||
def get_health_status() -> Dict[str, Any]:
|
||||
"""Get comprehensive health status"""
|
||||
checker = HealthChecker()
|
||||
return checker.run_full_health_check()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI usage: python core/health_check.py
|
||||
import sys
|
||||
import os
|
||||
|
||||
health = get_health_status()
|
||||
|
||||
print(json.dumps(health, indent=2))
|
||||
|
||||
# Exit with appropriate code
|
||||
if health["overall_status"] == "healthy":
|
||||
sys.exit(0)
|
||||
elif health["overall_status"] == "warning":
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(2)
|
||||
624
core/process_manager.py
Normal file
624
core/process_manager.py
Normal file
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
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. Llam.markdown_to_pdfar a PDFGenerator()
|
||||
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")
|
||||
# Leer el contenido markdown y pasarlo al generator
|
||||
markdown_content = md_path.read_text(encoding="utf-8")
|
||||
pdf_generator.markdown_to_pdf(markdown_content, 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
|
||||
16
docker/.dockerignore
Normal file
16
docker/.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
# Excluir archivos innecesarios del build
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
*.log
|
||||
downloads/
|
||||
transcriptions/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
!docker/README.md
|
||||
node_modules/
|
||||
.DS_Store
|
||||
14
docker/.env.example
Normal file
14
docker/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# CBCFacil - Configuración Docker
|
||||
# Copiar a .env y completar con tus credenciales
|
||||
|
||||
# API Keys
|
||||
ANTHROPIC_AUTH_TOKEN=tu_token_aqui
|
||||
|
||||
# Nextcloud
|
||||
NEXTCLOUD_URL=https://nextcloud.tudominio.com/remote.php/webdav
|
||||
NEXTCLOUD_USER=tu_usuario
|
||||
NEXTCLOUD_PASSWORD=tu_password
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_TOKEN=tu_token_bot
|
||||
TELEGRAM_CHAT_ID=tu_chat_id
|
||||
35
docker/Dockerfile
Normal file
35
docker/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# CBC OpenClaw - Imagen Docker minimal
|
||||
# Solo acceso a /app y las tools necesarias
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Crear usuario no-root para seguridad
|
||||
RUN useradd -m -s /bin/bash cbc && \
|
||||
mkdir -p /home/cbc && \
|
||||
chown -R cbc:cbc /home/cbc
|
||||
|
||||
# Definir workspace
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar solo archivos necesarios del proyecto
|
||||
COPY --chown=cbc:cbc . .
|
||||
|
||||
# Cambiar a usuario no-root
|
||||
USER cbc
|
||||
|
||||
# Variables de entorno para el agente
|
||||
ENV ANTHROPIC_API_KEY=""
|
||||
ENV ANTHROPIC_BASE_URL="https://api.minimax.io/anthropic"
|
||||
ENV ANTHROPIC_MODEL="MiniMax-M2.5"
|
||||
ENV HOME=/app
|
||||
|
||||
# El agente solo puede acceder a /app y sus subdirectorios
|
||||
# No tiene acceso a Internet directo (solo a través de variables de entorno)
|
||||
10
docker/README.md
Normal file
10
docker/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# CBC OpenClaw - Dockerizado con acceso limitado
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
docker/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .dockerignore
|
||||
└── README.md
|
||||
34
docker/docker-compose.yml
Normal file
34
docker/docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
cbc-openclaw:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: cbc-openclaw
|
||||
volumes:
|
||||
# Solo montar las carpetas necesarias
|
||||
- ../:/app
|
||||
# Montar credenciales desde variables de entorno o archivo seguro
|
||||
- ~/.env:/app/.env:ro
|
||||
environment:
|
||||
# API Keys - pasar desde host
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- ANTHROPIC_AUTH_TOKEN=${ANTHROPIC_AUTH_TOKEN}
|
||||
- ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
|
||||
- ANTHROPIC_MODEL=MiniMax-M2.5
|
||||
# Configuración CBC
|
||||
- NEXTCLOUD_URL=${NEXTCLOUD_URL}
|
||||
- NEXTCLOUD_USER=${NEXTCLOUD_USER}
|
||||
- NEXTCLOUD_PASSWORD=${NEXTCLOUD_PASSWORD}
|
||||
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||
working_dir: /app
|
||||
command: ["python3", "main.py"]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cbc-network
|
||||
|
||||
networks:
|
||||
cbc-network:
|
||||
driver: bridge
|
||||
20
docker/start.sh
Executable file
20
docker/start.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# CBC OpenClaw - Script de inicio Docker
|
||||
|
||||
set -e
|
||||
|
||||
# Cargar variables de entorno si existe .env
|
||||
if [ -f ".env" ]; then
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
fi
|
||||
|
||||
echo "🟢 Iniciando CBC OpenClaw..."
|
||||
|
||||
# Construir imagen si no existe
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
# Iniciar contenedor
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
echo "✅ CBC OpenClaw corriendo en http://localhost:5000"
|
||||
echo "📝 Ver logs: docker compose -f docker/docker-compose.yml logs -f"
|
||||
@@ -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
|
||||
846
main.py
846
main.py
@@ -1,64 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CBCFacil - Main Service Entry Point
|
||||
Unified AI service for document processing (audio, PDF, text)
|
||||
CBFacil - Sistema de transcripción de audio con IA y Notion
|
||||
|
||||
Características:
|
||||
- Polling de Nextcloud vía WebDAV
|
||||
- Transcripción con Whisper (medium, GPU)
|
||||
- Resúmenes con IA (MiniMax)
|
||||
- Generación de PDF
|
||||
- Notificaciones Telegram
|
||||
"""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Cargar variables de entorno desde .env
|
||||
load_dotenv()
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import fcntl
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
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
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""JSON formatter for structured logging in production"""
|
||||
import torch
|
||||
import torch.cuda
|
||||
|
||||
def format(self, record):
|
||||
log_entry = {
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"level": record.levelname,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno
|
||||
}
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
# API y configuración
|
||||
from api import api_bp, init_api
|
||||
from config import settings
|
||||
from processors.audio_processor import AudioProcessor, AudioProcessingError
|
||||
from services import WebDAVService
|
||||
from services.webdav_service import WebDAVService as WebDAVService_Class
|
||||
from services.telegram_service import TelegramService
|
||||
from watchers import RemoteFolderWatcher
|
||||
|
||||
return json.dumps(log_entry)
|
||||
# Importar ProcessManager del core
|
||||
from core.process_manager import ProcessManager as CoreProcessManager, ProcessInfo
|
||||
|
||||
# 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:
|
||||
"""Setup logging configuration"""
|
||||
from config import settings
|
||||
|
||||
# Create logger
|
||||
logger = logging.getLogger(__name__)
|
||||
"""Configura el sistema de logging."""
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper()))
|
||||
|
||||
# Remove existing handlers
|
||||
# Limpiar handlers existentes
|
||||
logger.handlers.clear()
|
||||
|
||||
# Console handler
|
||||
# Handler de consola
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
if settings.is_production:
|
||||
console_handler.setFormatter(JSONFormatter())
|
||||
else:
|
||||
console_handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] - %(name)s - %(message)s"
|
||||
console_handler.setFormatter(JSONFormatter(
|
||||
"%(timestamp)s %(level)s %(name)s %(message)s"
|
||||
))
|
||||
else:
|
||||
console_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler if configured
|
||||
# Handler de archivo si está configurado
|
||||
if 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)
|
||||
|
||||
return logger
|
||||
@@ -67,394 +91,428 @@ def setup_logging() -> logging.Logger:
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
# MONITOR GLOBAL - Solo UN archivo procesando a la vez
|
||||
# ============================================================================
|
||||
|
||||
class ProcessingMonitor:
|
||||
"""Monitor global para garantizar SOLO UN archivo en proceso a la vez."""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
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:
|
||||
"""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}")
|
||||
# Instancia global del monitor
|
||||
monitor = ProcessingMonitor()
|
||||
|
||||
|
||||
def validate_configuration() -> None:
|
||||
"""Validate configuration at startup"""
|
||||
from config.validators import validate_environment, ConfigurationError
|
||||
# ============================================================================
|
||||
# Polling Service
|
||||
# ============================================================================
|
||||
|
||||
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 __init__(self) -> None:
|
||||
self.webdav_service: Optional[WebDAVService_Class] = None
|
||||
self.watcher: Optional[RemoteFolderWatcher] = None
|
||||
self.flask_app: Optional[Flask] = None
|
||||
self._telegram_service: Optional[TelegramService] = None
|
||||
self._process_manager: Optional[CoreProcessManager] = None
|
||||
self._running = False
|
||||
|
||||
def check_service_health() -> dict:
|
||||
"""
|
||||
Check health of all external services
|
||||
Returns dict with health status
|
||||
"""
|
||||
from config import settings
|
||||
from services.webdav_service import webdav_service
|
||||
def initialize(self) -> None:
|
||||
"""Inicializa los servicios."""
|
||||
logger.info("Initializing CBCFacil polling service")
|
||||
|
||||
health_status = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"status": "healthy",
|
||||
"services": {}
|
||||
}
|
||||
# Verificar configuración
|
||||
if not settings.has_webdav_config:
|
||||
logger.error(
|
||||
"WebDAV configuration missing. Set NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 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"
|
||||
# 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")
|
||||
|
||||
# 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)
|
||||
}
|
||||
# Inicializar TelegramService
|
||||
self._telegram_service = TelegramService()
|
||||
logger.info(
|
||||
"Telegram service initialized",
|
||||
extra={"configured": self._telegram_service._configured},
|
||||
)
|
||||
|
||||
# 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)
|
||||
}
|
||||
# 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")
|
||||
|
||||
return health_status
|
||||
# Asignar a variable global para uso en _process_file_async_safe
|
||||
global process_manager
|
||||
process_manager = self._process_manager
|
||||
|
||||
# Inicializar watcher
|
||||
self.watcher = RemoteFolderWatcher(
|
||||
webdav_service=self.webdav_service,
|
||||
local_path=settings.DOWNLOADS_DIR,
|
||||
remote_path=settings.WATCHED_REMOTE_PATH,
|
||||
)
|
||||
self.watcher.set_callback(self._on_file_downloaded)
|
||||
self.watcher.start()
|
||||
|
||||
def initialize_services() -> None:
|
||||
"""Initialize all services with configuration validation"""
|
||||
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
|
||||
# Inicializar Flask
|
||||
self._setup_flask()
|
||||
|
||||
logger.info("Initializing services...")
|
||||
logger.info("CBCFacil initialized successfully")
|
||||
|
||||
# Validate configuration
|
||||
validate_configuration()
|
||||
def _setup_flask(self) -> None:
|
||||
"""Configura la aplicación Flask."""
|
||||
self.flask_app = Flask(__name__)
|
||||
CORS(self.flask_app)
|
||||
init_api(self._process_manager, self.webdav_service, self.watcher)
|
||||
self.flask_app.register_blueprint(api_bp)
|
||||
|
||||
# Warn if WebDAV not configured
|
||||
if not settings.has_webdav_config:
|
||||
logger.warning("WebDAV not configured - file sync functionality disabled")
|
||||
# Ruta principal
|
||||
@self.flask_app.route("/")
|
||||
def index():
|
||||
return {"message": "CBCFacil Polling Service", "version": "1.0.0"}
|
||||
|
||||
# Warn if AI providers not configured
|
||||
if not settings.has_ai_config:
|
||||
logger.warning("AI providers not configured - summary generation will not work")
|
||||
def _on_state_change(self, process_info: ProcessInfo) -> None:
|
||||
"""Callback cuando cambia el estado de un proceso."""
|
||||
filename = process_info.file_path.name
|
||||
|
||||
# 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}")
|
||||
# Enviar notificación apropiada según el estado
|
||||
if process_info.state.value == "transcribing" and self._telegram_service:
|
||||
self._telegram_service.send_start_notification(filename)
|
||||
|
||||
# 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)")
|
||||
def _on_process_complete(self, process_info: ProcessInfo) -> None:
|
||||
"""Callback cuando un proceso se completa exitosamente."""
|
||||
filename = process_info.file_path.name
|
||||
|
||||
# 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")
|
||||
if process_info.transcript:
|
||||
logger.info(
|
||||
"Transcripción completada",
|
||||
extra={"file_name": filename, "text_length": len(process_info.transcript)},
|
||||
)
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
def send_error_notification(error_type: str, error_message: str) -> None:
|
||||
"""Send error notification via Telegram"""
|
||||
try:
|
||||
from services.telegram_service import telegram_service
|
||||
if telegram_service.is_configured:
|
||||
telegram_service.send_error_notification(error_type, error_message)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send error notification: {e}")
|
||||
|
||||
|
||||
def run_main_loop() -> None:
|
||||
"""Main processing loop with improved error handling"""
|
||||
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()
|
||||
|
||||
consecutive_errors = 0
|
||||
max_consecutive_errors = 5
|
||||
|
||||
while True:
|
||||
try:
|
||||
logger.info("--- Polling for new files ---")
|
||||
processed_registry.load()
|
||||
|
||||
# Process PDFs
|
||||
if settings.has_webdav_config:
|
||||
try:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing PDFs: {e}")
|
||||
send_error_notification("pdf_processing", str(e))
|
||||
|
||||
# Process Audio files
|
||||
if settings.has_webdav_config:
|
||||
try:
|
||||
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):
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
from document.generators import DocumentGenerator
|
||||
from services.telegram_service import telegram_service
|
||||
|
||||
local_filename = unquote(Path(file_path).name)
|
||||
base_name = Path(local_filename).stem
|
||||
local_path = settings.LOCAL_DOWNLOADS_PATH / local_filename
|
||||
settings.LOCAL_DOWNLOADS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Step 1: Notify and download
|
||||
telegram_service.send_message(
|
||||
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",
|
||||
f"Service has failed {consecutive_errors} consecutive times"
|
||||
# Enviar notificación de completación
|
||||
if self._telegram_service:
|
||||
duration = (process_info.updated_at - process_info.created_at).total_seconds()
|
||||
self._telegram_service.send_completion_notification(
|
||||
filename=filename,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
# Don't exit, let the loop continue with backoff
|
||||
logger.info(f"Waiting {settings.POLL_INTERVAL * 2} seconds before retry...")
|
||||
time.sleep(settings.POLL_INTERVAL * 2)
|
||||
continue
|
||||
def _on_process_error(self, process_info: ProcessInfo) -> None:
|
||||
"""Callback cuando ocurre un error en un proceso."""
|
||||
filename = process_info.file_path.name
|
||||
error_msg = process_info.error or "Unknown error"
|
||||
|
||||
logger.info(f"Cycle completed. Waiting {settings.POLL_INTERVAL} seconds...")
|
||||
time.sleep(settings.POLL_INTERVAL)
|
||||
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."""
|
||||
# Verificar si ya fue procesado (existe transcripción con nombre exacto)
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
txt_path = transcriptions_dir / f"{file_path.stem}.txt"
|
||||
if txt_path.exists():
|
||||
logger.info(f"Skipping already processed file: {file_path.name}")
|
||||
return
|
||||
|
||||
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:
|
||||
logger.debug(f"File already in queue: {file_path.name}")
|
||||
|
||||
def run(self) -> None:
|
||||
"""Ejecuta el servicio."""
|
||||
self._running = True
|
||||
|
||||
# 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"}
|
||||
|
||||
# Obtener transcripciones existentes - verificar por nombre EXACTO
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
|
||||
# Filtrar solo archivos que NO han sido procesados
|
||||
pending_files = []
|
||||
for f in downloads_dir.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in audio_extensions and not f.name.startswith("."):
|
||||
# Verificar si ya existe transcripción con el MISMO nombre
|
||||
txt_path = transcriptions_dir / f"{f.stem}.txt"
|
||||
if not txt_path.exists():
|
||||
# No existe .txt, agregar a pendientes
|
||||
pending_files.append(f)
|
||||
|
||||
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"""
|
||||
lock_fd = None
|
||||
# ============================================================================
|
||||
# Función segura de procesamiento de archivos
|
||||
# ============================================================================
|
||||
|
||||
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:
|
||||
logger.info("=== CBCFacil Service Started ===")
|
||||
logger.info(f"Version: {os.getenv('APP_VERSION', '8.0')}")
|
||||
logger.info(f"Environment: {'production' if os.getenv('DEBUG', 'false').lower() != 'true' else 'development'}")
|
||||
if process_manager is None:
|
||||
logger.error("ProcessManager not initialized")
|
||||
return
|
||||
|
||||
lock_fd = acquire_lock()
|
||||
initialize_services()
|
||||
run_main_loop()
|
||||
logger.info(
|
||||
"Starting file processing",
|
||||
extra={"file": file_path.name},
|
||||
)
|
||||
|
||||
# Procesar el archivo
|
||||
process_manager.process_file(file_path)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutdown requested by user")
|
||||
except Exception as e:
|
||||
logger.exception(f"Fatal error in main: {e}")
|
||||
send_error_notification("fatal_error", str(e))
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if lock_fd:
|
||||
release_lock(lock_fd)
|
||||
logger.info("=== CBCFacil Service Stopped ===")
|
||||
logger.exception(
|
||||
"Error processing file",
|
||||
extra={
|
||||
"file_name": file_path.name,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# 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__":
|
||||
# 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])
|
||||
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()
|
||||
sys.exit(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 @@
|
||||
"""
|
||||
Processors package for CBCFacil
|
||||
"""
|
||||
"""Procesadores de documentos y medios."""
|
||||
|
||||
from .base_processor import FileProcessor
|
||||
from .audio_processor import AudioProcessor
|
||||
from .pdf_processor import PDFProcessor
|
||||
from .text_processor import TextProcessor
|
||||
from processors.audio_processor import AudioProcessor, AudioProcessingError
|
||||
|
||||
__all__ = [
|
||||
'FileProcessor',
|
||||
'AudioProcessor',
|
||||
'PDFProcessor',
|
||||
'TextProcessor'
|
||||
]
|
||||
__all__ = ["AudioProcessor", "AudioProcessingError"]
|
||||
|
||||
@@ -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 os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from core import FileProcessingError
|
||||
from typing import Dict, Literal, Optional, Tuple
|
||||
|
||||
import whisper
|
||||
|
||||
from config import settings
|
||||
from services 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
|
||||
from services.vram_manager import vram_manager
|
||||
|
||||
|
||||
class AudioProcessor(FileProcessor):
|
||||
"""Processor for audio files using Whisper"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
"""Check if file is an audio file"""
|
||||
ext = self.get_file_extension(file_path)
|
||||
return ext in settings.AUDIO_EXTENSIONS
|
||||
# ============ CONFIGURACIÓN DE OPTIMIZACIONES ============
|
||||
|
||||
def _load_model(self):
|
||||
"""Load Whisper model lazily"""
|
||||
if not WHISPER_AVAILABLE:
|
||||
raise FileProcessingError("Whisper not installed")
|
||||
# CRÍTICO: Permite segmentos expandibles para reducir fragmentación
|
||||
os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
|
||||
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
||||
|
||||
if self._model is None:
|
||||
device = gpu_detector.get_device()
|
||||
self.logger.info(f"Loading Whisper model: {self._model_name} on {device}")
|
||||
self._model = whisper.load_model(self._model_name, device=device)
|
||||
vram_manager.update_usage()
|
||||
# Tamaños de modelos en GB (incluyendo overhead)
|
||||
MODEL_MEMORY_REQUIREMENTS = {
|
||||
"tiny": 0.5, "base": 0.8, "small": 1.5,
|
||||
"medium": 2.5, "large": 4.5,
|
||||
}
|
||||
|
||||
def process(self, file_path: str) -> Dict[str, Any]:
|
||||
"""Transcribe audio file"""
|
||||
self.validate_file(file_path)
|
||||
audio_path = Path(file_path)
|
||||
output_path = settings.LOCAL_DOWNLOADS_PATH / f"{audio_path.stem}.txt"
|
||||
# Cache global singleton - CLAVE para evitar OOM
|
||||
_model_cache: Dict[str, Tuple[whisper.Whisper, str, float]] = {}
|
||||
|
||||
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:
|
||||
# Load model if needed
|
||||
self._load_model()
|
||||
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-show_entries", "stream=channels,sample_rate,codec_name",
|
||||
"-of", "json", str(audio_path)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
# Update VRAM usage
|
||||
vram_manager.update_usage()
|
||||
if result.returncode == 0:
|
||||
import json
|
||||
info = json.loads(result.stdout)
|
||||
duration = float(info.get("format", {}).get("duration", 0))
|
||||
|
||||
# Transcribe with torch.no_grad() for memory efficiency
|
||||
with torch.inference_mode():
|
||||
result = self._model.transcribe(
|
||||
str(audio_path),
|
||||
language="es",
|
||||
fp16=True,
|
||||
verbose=False
|
||||
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
|
||||
|
||||
return {"duration": 0, "sample_rate": 16000, "channels": 1,
|
||||
"codec": "unknown", "size_bytes": file_size}
|
||||
|
||||
def _convert_audio_with_ffmpeg(self, input_path: Path, output_format: str = "wav") -> Path:
|
||||
"""Convierte audio usando ffmpeg."""
|
||||
suffix = f".{output_format}"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
output_path = Path(tmp.name)
|
||||
|
||||
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
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(result["text"])
|
||||
# Limpiar cache después de cargar
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
self.logger.info(f"Transcription completed: {output_path}")
|
||||
cache_model(self._model_name, self._model, self._resolved_device)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transcription_path": str(output_path),
|
||||
"text": result["text"],
|
||||
"model_used": self._model_name
|
||||
}
|
||||
gpu_info = get_gpu_memory_info()
|
||||
logger.info(
|
||||
f"Modelo cargado en {self._resolved_device}",
|
||||
extra={"gpu_used_gb": round(gpu_info.get("used", 0), 2),
|
||||
"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:
|
||||
self.logger.error(f"Audio processing failed: {e}")
|
||||
raise FileProcessingError(f"Audio processing failed: {e}")
|
||||
raise AudioProcessingError(f"Error cargando modelo: {e}") from e
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup model"""
|
||||
def _transcribe_internal(self, audio_path: Path, audio_properties: dict) -> str:
|
||||
"""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:
|
||||
del self._model
|
||||
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
|
||||
Flask>=3.0.0
|
||||
Flask-CORS>=4.0.0
|
||||
|
||||
# AI/ML dependencies
|
||||
torch>=2.0.0
|
||||
torchvision>=0.15.0
|
||||
# CBCFacil - Dependencias del proyecto
|
||||
anthropic>=0.18.0
|
||||
flask>=3.0.0
|
||||
flask-cors>=4.0.0
|
||||
httpx>=0.27.0
|
||||
markdown>=3.5.0
|
||||
openai-whisper>=20231117
|
||||
transformers>=4.30.0
|
||||
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
|
||||
pydub>=0.25.1
|
||||
python-dotenv>=1.0.0
|
||||
webdavclient3>=0.9.8
|
||||
|
||||
# Optional: for enhanced functionality
|
||||
# unidecode>=1.3.7 # For filename normalization
|
||||
# python-magic>=0.4.27 # For file type detection
|
||||
python-json-logger>=2.0.0
|
||||
reportlab>=4.0.0
|
||||
torch[cuda]>=2.0.0
|
||||
watchdog>=4.0.0
|
||||
webdavclient3>=0.4.2
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
"""
|
||||
Services package for CBCFacil
|
||||
"""
|
||||
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'
|
||||
]
|
||||
"""Export de servicios."""
|
||||
from .webdav_service import WebDAVService
|
||||
|
||||
__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()
|
||||
212
services/ai_summary_service.py
Normal file
212
services/ai_summary_service.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""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
|
||||
|
||||
# Prompt siguiendo código.md - resumen académico en español
|
||||
default_prompt = """Eres un asistente académico especializado en crear resúmenes de estudio de alta calidad.
|
||||
|
||||
INSTRUCCIONES OBLIGATORIAS:
|
||||
1. Escribe ÚNICAMENTE en español
|
||||
2. El resumen debe seguir esta estructura:
|
||||
- Título y objetivo de estudio
|
||||
- Índice con 6-12 secciones
|
||||
- Desarrollo conceptual (definiciones, mecanismos)
|
||||
- Casos de aplicación (ejemplos concretos)
|
||||
- Errores frecuentes
|
||||
- Checklist de repaso
|
||||
3. Cada concepto debe explicar: qué es, por qué importa, cómo se aplica
|
||||
4. Evita listas sin explicación - siempre incluir el "por qué"
|
||||
5. Para TABLAS usa formato LaTeX tabular:
|
||||
\\begin{{tabular}}{{|c|l|l|}}
|
||||
\\hline
|
||||
Encabezado 1 & Encabezado 2 & Encabezado 3 \\\\
|
||||
\\hline
|
||||
dato1 & dato2 & dato3 \\\\
|
||||
\\hline
|
||||
\\end{{tabular}}
|
||||
6. NO uses tablas ASCII ni markdown con | pipes
|
||||
7. El resumen debe poder leerse en 15-25 minutos
|
||||
8. NO incluyas rutas de archivos ni referencias técnicas
|
||||
9. Sé conciso pero con densidad informativa útil para exámenes
|
||||
|
||||
Transcripción de clase:
|
||||
{text}
|
||||
|
||||
Genera el resumen siguiendo las instrucciones arriba."""
|
||||
|
||||
prompt = prompt_template.format(text=text) if prompt_template else default_prompt.format(text=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", "")
|
||||
|
||||
# Limpiar respuesta: eliminar thinking tokens y ruido
|
||||
# Buscar el primer encabezado markdown y cortar ahí
|
||||
first_header = summary.find("\n# ")
|
||||
if first_header == -1:
|
||||
first_header = summary.find("# ")
|
||||
if first_header > 0:
|
||||
summary = summary[first_header:]
|
||||
|
||||
# Eliminar bloques de think/error si persisten
|
||||
lines = summary.split("\n")
|
||||
clean_lines = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("<think>") or line.strip().endswith("</think>"):
|
||||
skip = True
|
||||
continue
|
||||
if skip and line.strip() and not line.startswith(" "):
|
||||
skip = False
|
||||
if not skip:
|
||||
clean_lines.append(line)
|
||||
summary = "\n".join(clean_lines)
|
||||
|
||||
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()
|
||||
}
|
||||
375
services/pdf_generator.py
Normal file
375
services/pdf_generator.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
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 Optional, 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, Table, TableStyle
|
||||
|
||||
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_latex_table(self, lines: list[str], start_idx: int) -> tuple[Optional[Table], int]:
|
||||
"""
|
||||
Parsea una tabla LaTeX y la convierte a reportlab Table.
|
||||
|
||||
Returns:
|
||||
(Table, end_index) - La tabla y el índice donde termina
|
||||
"""
|
||||
# Buscar begin/end tabular
|
||||
table_lines = []
|
||||
i = start_idx
|
||||
in_table = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
|
||||
if "\\begin{tabular}" in line or "begin{tabular}" in line:
|
||||
in_table = True
|
||||
# Extraer especificaciones de columnas
|
||||
col_spec = "l"
|
||||
if "{" in line:
|
||||
col_spec = line.split("{")[1].split("}")[0] if "}" in line else "l"
|
||||
table_lines.append({"type": "spec", "data": col_spec})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if "\\end{tabular}" in line or "end{tabular}" in line:
|
||||
in_table = False
|
||||
break
|
||||
|
||||
if in_table:
|
||||
# Saltar líneas de hline
|
||||
if "hline" in line.replace("\\", "").replace(" ", ""):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Procesar línea de tabla
|
||||
# Reemplazar & por separador
|
||||
row_data = line.replace("&", "|")
|
||||
# Eliminar comandos LaTeX
|
||||
row_data = row_data.replace("\\", "").replace("\\\\", "").replace("hline", "")
|
||||
cells = [c.strip() for c in row_data.split("|") if c.strip()]
|
||||
# Filtrar celdas vacías
|
||||
cells = [c for c in cells if c and c != "|"]
|
||||
if cells and len(cells) > 1: # Al menos 2 columnas para ser tabla válida
|
||||
table_lines.append({"type": "row", "data": cells})
|
||||
|
||||
i += 1
|
||||
|
||||
if not table_lines:
|
||||
return None, start_idx
|
||||
|
||||
# Convertir a Table de reportlab
|
||||
data = []
|
||||
col_widths = None
|
||||
|
||||
for tl in table_lines:
|
||||
if tl["type"] == "row":
|
||||
# Limpiar celdas de LaTeX
|
||||
row = []
|
||||
for cell in tl["data"]:
|
||||
cell = cell.strip()
|
||||
# Eliminar comandos LaTeX restantes (manejar {contenido})
|
||||
import re
|
||||
# Eliminar \textbf{...}, \textit{...}, \emph{...}
|
||||
cell = re.sub(r'\\textbf\{([^}]*)\}', r'\1', cell)
|
||||
cell = re.sub(r'\\textit\{([^}]*)\}', r'\1', cell)
|
||||
cell = re.sub(r'\\emph\{([^}]*)\}', r'\1', cell)
|
||||
cell = cell.replace("\\", "").replace("{", "").replace("}", "")
|
||||
cell = cell.strip()
|
||||
if cell:
|
||||
row.append(cell)
|
||||
if row:
|
||||
data.append(row)
|
||||
|
||||
if not data:
|
||||
return None, start_idx
|
||||
|
||||
# Crear tabla
|
||||
try:
|
||||
num_cols = len(data[0]) if data else 1
|
||||
table = Table(data)
|
||||
table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-0, -1), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
]))
|
||||
return table, i
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing LaTeX table: {e}")
|
||||
return None, start_idx
|
||||
|
||||
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))
|
||||
# Tabla LaTeX
|
||||
elif "begin{tabular}" in line or "begin{tabular" in line:
|
||||
latex_table, end_idx = self._parse_latex_table(lines, idx)
|
||||
if latex_table:
|
||||
elements.append(Spacer(1, 0.3 * cm))
|
||||
elements.append(latex_table)
|
||||
elements.append(Spacer(1, 0.3 * cm))
|
||||
idx = end_idx - 1 # Saltar las líneas de la tabla
|
||||
# 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 time
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from config import settings
|
||||
|
||||
try:
|
||||
import requests
|
||||
REQUESTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
REQUESTS_AVAILABLE = False
|
||||
import requests
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
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:
|
||||
"""Service for sending Telegram notifications"""
|
||||
"""Servicio para enviar notificaciones a Telegram."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._token: Optional[str] = None
|
||||
self._chat_id: Optional[str] = None
|
||||
self._last_error_cache: dict = {}
|
||||
def __init__(self) -> None:
|
||||
"""Inicializa el servicio si hay configuración de Telegram."""
|
||||
self._token: Optional[str] = settings.TELEGRAM_TOKEN
|
||||
self._chat_id: Optional[str] = settings.TELEGRAM_CHAT_ID
|
||||
self._configured: bool = settings.has_telegram_config
|
||||
|
||||
def configure(self, token: str, chat_id: str) -> None:
|
||||
"""Configure Telegram credentials"""
|
||||
self._token = token
|
||||
self._chat_id = chat_id
|
||||
self.logger.info("Telegram service configured")
|
||||
# Rate limiting: mínimo tiempo entre mensajes (segundos)
|
||||
self._min_interval: float = 1.0
|
||||
self._last_send_time: float = 0.0
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Telegram is configured"""
|
||||
return bool(self._token and self._chat_id)
|
||||
if self._configured:
|
||||
logger.info(
|
||||
"TelegramService inicializado",
|
||||
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:
|
||||
"""Make API request to Telegram"""
|
||||
if not REQUESTS_AVAILABLE:
|
||||
self.logger.warning("requests library not available")
|
||||
def _mask_chat_id(self) -> str:
|
||||
"""Oculta el chat_id para logging seguro."""
|
||||
if self._chat_id and len(self._chat_id) > 4:
|
||||
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
|
||||
|
||||
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:
|
||||
resp = requests.post(url, data=data, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
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
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
result = {"raw": response.text}
|
||||
|
||||
def send_message(self, message: str) -> bool:
|
||||
"""Send a text message to Telegram"""
|
||||
if not self.is_configured:
|
||||
self.logger.warning("Telegram not configured, skipping notification")
|
||||
if response.status_code == 200 and result.get("ok"):
|
||||
logger.debug(
|
||||
"Mensaje enviado exitosamente",
|
||||
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
|
||||
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)
|
||||
|
||||
def send_start_notification(self) -> bool:
|
||||
"""Send service start notification"""
|
||||
message = "CBCFacil Service Started - AI document processing active"
|
||||
return self.send_message(message)
|
||||
def send_start_notification(self, filename: str) -> bool:
|
||||
"""
|
||||
Envía notificación de inicio de procesamiento.
|
||||
|
||||
def send_error_notification(self, error_key: str, error_message: str) -> bool:
|
||||
"""Send error notification with throttling"""
|
||||
now = datetime.utcnow()
|
||||
prev = self._last_error_cache.get(error_key)
|
||||
if prev is None:
|
||||
self._last_error_cache[error_key] = (error_message, now)
|
||||
Args:
|
||||
filename: Nombre del archivo que se está procesando.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
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:
|
||||
prev_msg, prev_time = prev
|
||||
if error_message != prev_msg or (now - prev_time).total_seconds() > settings.ERROR_THROTTLE_SECONDS:
|
||||
self._last_error_cache[error_key] = (error_message, now)
|
||||
else:
|
||||
return False
|
||||
return self.send_message(f"Error: {error_message}")
|
||||
length_text = f"{text_length} caracteres"
|
||||
|
||||
text = f"✅ <b>Transcripción completada</b>\n\n📄 <code>{safe_filename}</code>\n📝 {length_text}"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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 logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from core import BaseService
|
||||
from config import settings
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
try:
|
||||
import torch
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
from config.settings import settings
|
||||
|
||||
# Import gpu_detector after torch check
|
||||
from .gpu_detector import gpu_detector, GPUType
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VRAMManager(BaseService):
|
||||
"""Service for managing GPU VRAM usage"""
|
||||
def get_gpu_memory_mb() -> Dict[str, float]:
|
||||
"""
|
||||
Obtiene uso de memoria GPU en MB.
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("VRAMManager")
|
||||
self._whisper_model = None
|
||||
self._ocr_models = None
|
||||
self._trocr_models = None
|
||||
self._models_last_used: Optional[datetime] = None
|
||||
self._cleanup_threshold = 0.7
|
||||
self._cleanup_interval = 300
|
||||
self._last_cleanup: Optional[datetime] = None
|
||||
Returns:
|
||||
Dict con 'total', 'used', 'free' en MB.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize VRAM manager"""
|
||||
# Initialize GPU detector first
|
||||
gpu_detector.initialize()
|
||||
if torch.cuda.is_available():
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
total = props.total_memory / (1024 ** 2)
|
||||
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
|
||||
return {
|
||||
"total": total,
|
||||
"used": allocated,
|
||||
"free": total - reserved,
|
||||
"reserved": reserved,
|
||||
}
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Error obteniendo memoria GPU: {e}")
|
||||
|
||||
if gpu_detector.is_available():
|
||||
gpu_type = gpu_detector.gpu_type
|
||||
device_name = gpu_detector.get_device_name()
|
||||
return {"total": 0, "used": 0, "free": 0, "reserved": 0}
|
||||
|
||||
if gpu_type == GPUType.AMD:
|
||||
self.logger.info(f"VRAM Manager initialized with AMD ROCm: {device_name}")
|
||||
elif gpu_type == GPUType.NVIDIA:
|
||||
os.environ['CUDA_VISIBLE_DEVICES'] = settings.CUDA_VISIBLE_DEVICES
|
||||
if settings.PYTORCH_CUDA_ALLOC_CONF:
|
||||
torch.backends.cuda.max_split_size_mb = int(settings.PYTORCH_CUDA_ALLOC_CONF.split(':')[1])
|
||||
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:
|
||||
"""Cleanup all GPU models"""
|
||||
if not TORCH_AVAILABLE or not torch.cuda.is_available():
|
||||
return
|
||||
def clear_cuda_cache(aggressive: bool = False) -> None:
|
||||
"""
|
||||
Limpia el cache de CUDA.
|
||||
|
||||
models_freed = []
|
||||
Args:
|
||||
aggressive: Si True, ejecuta gc.collect() múltiples veces.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
if self._whisper_model is not None:
|
||||
try:
|
||||
del self._whisper_model
|
||||
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:
|
||||
try:
|
||||
self._ocr_models = None
|
||||
models_freed.append("OCR")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error freeing OCR VRAM: {e}")
|
||||
|
||||
if self._trocr_models is not None:
|
||||
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)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
after_allocated = torch.cuda.memory_allocated(0) / 1024**3
|
||||
after_reserved = torch.cuda.memory_reserved(0) / 1024**3
|
||||
self.logger.debug(f"After cleanup - Allocated: {after_allocated:.2f}GB, Reserved: {after_reserved:.2f}GB")
|
||||
if after_reserved < before_reserved:
|
||||
self.logger.info(f"VRAM freed: {(before_reserved - after_reserved):.2f}GB")
|
||||
|
||||
if aggressive:
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
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:
|
||||
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]:
|
||||
"""Get VRAM usage information"""
|
||||
if not TORCH_AVAILABLE:
|
||||
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_unload(self, model_id: str) -> bool:
|
||||
"""
|
||||
Fuerza la descarga inmediata de un modelo.
|
||||
|
||||
def force_free(self) -> str:
|
||||
"""Force immediate VRAM free"""
|
||||
self.cleanup()
|
||||
return "VRAM freed successfully"
|
||||
Args:
|
||||
model_id: Identificador del modelo a descargar.
|
||||
|
||||
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()
|
||||
|
||||
@@ -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 os
|
||||
import time
|
||||
import unicodedata
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
from contextlib import contextmanager
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from requests.adapters import HTTPAdapter
|
||||
from typing import Optional
|
||||
from webdav3.client import Client
|
||||
|
||||
from config import settings
|
||||
from core import WebDAVError
|
||||
|
||||
|
||||
class WebDAVService:
|
||||
"""Service for WebDAV operations with Nextcloud"""
|
||||
"""Cliente WebDAV para Nextcloud."""
|
||||
|
||||
def __init__(self):
|
||||
self.session: Optional[requests.Session] = None
|
||||
def __init__(self) -> None:
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._retry_delay = 1
|
||||
self._max_retries = settings.WEBDAV_MAX_RETRIES
|
||||
self._client: Optional[Client] = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize WebDAV session"""
|
||||
if not settings.has_webdav_config:
|
||||
raise WebDAVError("WebDAV credentials not configured")
|
||||
def _get_client(self) -> Client:
|
||||
"""Obtiene o crea el cliente WebDAV."""
|
||||
if self._client is None:
|
||||
if not settings.has_webdav_config:
|
||||
raise RuntimeError("WebDAV configuration missing")
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.auth = HTTPBasicAuth(settings.NEXTCLOUD_USER, settings.NEXTCLOUD_PASSWORD)
|
||||
options = {
|
||||
"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
|
||||
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)
|
||||
return self._client
|
||||
|
||||
# Test connection
|
||||
def test_connection(self) -> bool:
|
||||
"""Prueba la conexión con Nextcloud."""
|
||||
try:
|
||||
self._request('GET', '', timeout=5)
|
||||
self.logger.info("WebDAV connection established")
|
||||
client = self._get_client()
|
||||
return client.check()
|
||||
except Exception as e:
|
||||
raise WebDAVError(f"Failed to connect to WebDAV: {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:
|
||||
self.logger.error(f"WebDAV connection failed: {e}")
|
||||
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
|
||||
webdav_service = WebDAVService()
|
||||
files = client.list(remote_path)
|
||||
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"]
|
||||
229
watchers/folder_watcher.py
Normal file
229
watchers/folder_watcher.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# Verificar si ya existe el archivo localmente
|
||||
if local_path.exists():
|
||||
# Verificar si ya fue procesado (existe transcripción con nombre EXACTO)
|
||||
stem = local_path.stem
|
||||
transcriptions_dir = self.local_path.parent / "transcriptions"
|
||||
txt_path = transcriptions_dir / f"{stem}.txt"
|
||||
|
||||
if txt_path.exists():
|
||||
self.logger.info(f"Skipping already processed file: {filename}")
|
||||
return
|
||||
|
||||
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