Compare commits

..

10 Commits

Author SHA1 Message Date
renato97
ee8fc183be feat: Sistema CBCFacil completo con cola secuencial
- Implementa ProcessingMonitor singleton para procesamiento secuencial de archivos
- Agrega AI summary service con soporte para MiniMax API
- Agrega PDF generator para resúmenes
- Agrega watchers para monitoreo de carpeta remota
- Mejora sistema de notificaciones Telegram
- Implementa gestión de VRAM para GPU
- Configuración mediante variables de entorno (sin hardcoded secrets)
- .env y transcriptions/ agregados a .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:35:39 +00:00
renato97
dcf887c510 feat: Sistema LaTeX mejorado con sanitización automática y corrección de TikZ
Cambios principales:

## Nuevos archivos
- services/ai/parallel_provider.py: Ejecución paralela de múltiples proveedores AI
- services/ai/prompt_manager.py: Gestión centralizada de prompts (resumen.md como fuente)
- latex/resumen.md: Template del prompt para resúmenes académicos LaTeX

## Mejoras en generación LaTeX (document/generators.py)
- Nueva función _sanitize_latex(): Corrige automáticamente errores comunes de AI
  - Agrega align=center a nodos TikZ con saltos de línea (\\)
  - Previene errores 'Not allowed in LR mode' antes de compilar
- Soporte para procesamiento paralelo de proveedores AI
- Conversión DOCX en paralelo con generación PDF
- Uploads a Notion en background (non-blocking)
- Callbacks de notificación para progreso en Telegram

## Mejoras en proveedores AI
- claude_provider.py: fix_latex() con instrucciones específicas para errores TikZ
- gemini_provider.py: fix_latex() mejorado + rate limiting + circuit breaker
- provider_factory.py: Soporte para parallel provider

## Otros cambios
- config/settings.py: Nuevas configuraciones para Gemini models
- services/webdav_service.py: Mejoras en manejo de conexión
- .gitignore: Ignora archivos LaTeX auxiliares (.aux, .toc, .out, .pdf)

## Archivos de ejemplo
- latex/imperio_romano.tex, latex/clase_revolucion_rusa_crisis_30.tex
- resumen_curiosidades.tex (corregido y compilado exitosamente)
2026-02-07 20:50:27 +00:00
renato97
915f827305 feat: Implementación de Resúmenes Matemáticos con LaTeX y Pandoc
##  Novedades
- **Soporte LaTeX**: Generación de PDFs y DOCX con fórmulas matemáticas renderizadas correctamente usando Pandoc.
- **Sanitización Automática**: Corrección de caracteres Unicode (griegos/cirílicos) y sintaxis LaTeX para evitar errores de compilación.
- **GLM/Claude Prioritario**: Cambio de proveedor de IA predeterminado a Claude/GLM para mayor estabilidad y capacidad de razonamiento.
- **Mejoras en Formato**: El formateo final del resumen ahora usa el modelo principal (GLM) en lugar de Gemini para consistencia.

## 🛠️ Cambios Técnicos
- `document/generators.py`: Reemplazo de generación manual por `pandoc`. Añadida función `_sanitize_latex`.
- `services/ai/claude_provider.py`: Soporte mejorado para variables de entorno de Z.ai.
- `services/ai/provider_factory.py`: Prioridad ajustada `Claude > Gemini`.
- `latex/`: Añadida documentación de referencia para el pipeline LaTeX.
2026-01-26 23:40:16 +00:00
renato97
f9d245a58e Token already removed 2026-01-26 17:31:17 +00:00
renato97
6058dc642e feat: Integración automática con Notion + análisis completo del código
- Instalado notion-client SDK oficial para integración robusta
- Refactorizado services/notion_service.py con SDK oficial de Notion
  - Rate limiting con retry y exponential backoff
  - Parser Markdown → Notion blocks (headings, bullets, paragraphs)
  - Soporte para pages y databases
  - Manejo robusto de errores

- Integración automática en document/generators.py
  - PDFs se suben automáticamente a Notion después de generarse
  - Contenido completo del resumen formateado con bloques
  - Metadata rica (tipo de archivo, path, fecha)

- Configuración de Notion en main.py
  - Inicialización automática al arrancar el servicio
  - Validación de credenciales

- Actualizado config/settings.py
  - Agregado load_dotenv() para cargar variables de .env
  - Configuración de Notion (NOTION_API, NOTION_DATABASE_ID)

- Scripts de utilidad creados:
  - test_notion_integration.py: Test de subida a Notion
  - test_pipeline_notion.py: Test del pipeline completo
  - verify_notion_permissions.py: Verificación de permisos
  - list_notion_pages.py: Listar páginas accesibles
  - diagnose_notion.py: Diagnóstico completo
  - create_notion_database.py: Crear database automáticamente
  - restart_service.sh: Script de reinicio del servicio

- Documentación completa en opus.md:
  - Análisis exhaustivo del codebase (42 archivos Python)
  - Bugs críticos identificados y soluciones
  - Mejoras de seguridad (autenticación, rate limiting, CORS, CSP)
  - Optimizaciones de rendimiento (Celery, Redis, PostgreSQL, WebSockets)
  - Plan de testing (estructura, ejemplos, 80% coverage goal)
  - Roadmap de implementación (6 sprints detallados)
  - Integración avanzada con Notion documentada

Estado: Notion funcionando correctamente, PDFs se suben automáticamente
2026-01-26 17:31:17 +00:00
renato97
47896fd50a docs: nuevo README completo con especificaciones del dashboard y API 2026-01-10 19:34:57 +00:00
renato97
f04c1cd548 feat: dashboard integrado en thread separado + documentación
🚀 Mejoras principales:
- Dashboard Flask ahora corre en thread daemon independiente
- Integración con python-dotenv para variables de entorno
- Configuración de puerto vía DASHBOARD_PORT (default: 5000)
- Mejor logging con Thread-ID para debugging

📦 Nuevos archivos:
- kubectl: binary de Kubernetes para deployments
- plus.md: documentación adicional del proyecto
- todo.md: roadmap y tareas pendientes

🔧 Cambios técnicos:
- run_dashboard_thread(): ejecuta Flask en thread separado
- start_dashboard(): crea y arranca daemon thread
- Configuración de reloader desactivado en threaded mode

Esto permite que el dashboard corra sin bloquear el loop principal
de procesamiento, mejorando la arquitectura del servicio.
2026-01-10 19:32:08 +00:00
renato97
75ef0afcb1 feat(dashboard): agregar panel de versiones y corregir carga de transcripciones
- Corregir endpoints /api/transcription y /api/summary para manejar filenames con extensión
- Agregar endpoint /api/versions para listar archivos generados
- Agregar tab 'Versiones' en panel lateral con lista de archivos
- Mejorar modal de progreso con barra animada y estados
- Cambiar archivos para que se abran en pestaña en lugar de descargarse
- Agregar botón 'Regenerar' en lista de archivos procesados
2026-01-10 19:18:14 +00:00
312e303563 Cleanup: Remove legacy Docker files (Dockerfile, docker-compose) and docs 2026-01-09 18:35:15 -03:00
f7fdb0b622 Refine formatting: Justified text, robust PDF/DOCX generation, clean markdown style 2026-01-09 18:33:38 -03:00
62 changed files with 3765 additions and 8891 deletions

View File

@@ -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
View File

@@ -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/

View File

@@ -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.

View File

@@ -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
View File

@@ -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.

View File

@@ -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

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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
View 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.

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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

View File

@@ -1,355 +0,0 @@
"""
Health check endpoint for CBCFacil service monitoring
"""
import json
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from pathlib import Path
logger = logging.getLogger(__name__)
class HealthChecker:
"""Comprehensive health check for all service dependencies"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def check_webdav_connection(self) -> Dict[str, Any]:
"""Check WebDAV service connectivity"""
from config import settings
result = {
"service": "webdav",
"status": "unknown",
"timestamp": datetime.utcnow().isoformat()
}
try:
from services.webdav_service import webdav_service
if not settings.has_webdav_config:
result["status"] = "not_configured"
result["message"] = "WebDAV credentials not configured"
return result
# Test connection with a simple list operation
webdav_service.list(".")
result["status"] = "healthy"
result["message"] = "WebDAV connection successful"
result["endpoint"] = settings.NEXTCLOUD_URL
except Exception as e:
result["status"] = "unhealthy"
result["error"] = str(e)
self.logger.error(f"WebDAV health check failed: {e}")
return result
def check_ai_providers(self) -> Dict[str, Any]:
"""Check AI provider configurations"""
from config import settings
result = {
"service": "ai_providers",
"status": "unknown",
"timestamp": datetime.utcnow().isoformat(),
"providers": {}
}
try:
# Check ZAI
if settings.ZAI_AUTH_TOKEN:
result["providers"]["zai"] = {
"configured": True,
"status": "unknown"
}
else:
result["providers"]["zai"] = {
"configured": False,
"status": "not_configured"
}
# Check Gemini
if settings.GEMINI_API_KEY:
result["providers"]["gemini"] = {
"configured": True,
"status": "unknown"
}
else:
result["providers"]["gemini"] = {
"configured": False,
"status": "not_configured"
}
# Check CLI providers
if settings.CLAUDE_CLI_PATH:
claude_path = Path(settings.CLAUDE_CLI_PATH)
result["providers"]["claude_cli"] = {
"configured": True,
"path_exists": claude_path.exists(),
"status": "available" if claude_path.exists() else "path_invalid"
}
if settings.GEMINI_CLI_PATH:
gemini_path = Path(settings.GEMINI_CLI_PATH)
result["providers"]["gemini_cli"] = {
"configured": True,
"path_exists": gemini_path.exists(),
"status": "available" if gemini_path.exists() else "path_invalid"
}
# Overall status
if settings.has_ai_config:
result["status"] = "healthy"
result["message"] = "At least one AI provider configured"
else:
result["status"] = "not_configured"
result["message"] = "No AI providers configured"
except Exception as e:
result["status"] = "error"
result["error"] = str(e)
self.logger.error(f"AI providers health check failed: {e}")
return result
def check_vram_manager(self) -> Dict[str, Any]:
"""Check VRAM manager status"""
result = {
"service": "vram_manager",
"status": "unknown",
"timestamp": datetime.utcnow().isoformat()
}
try:
from services.vram_manager import vram_manager
vram_info = vram_manager.get_vram_info()
result["status"] = "healthy"
result["vram_info"] = {
"total_gb": round(vram_info.get("total", 0) / (1024**3), 2),
"free_gb": round(vram_info.get("free", 0) / (1024**3), 2),
"allocated_gb": round(vram_info.get("allocated", 0) / (1024**3), 2)
}
result["cuda_available"] = vram_info.get("cuda_available", False)
except Exception as e:
result["status"] = "unavailable"
result["error"] = str(e)
self.logger.error(f"VRAM manager health check failed: {e}")
return result
def check_telegram_service(self) -> Dict[str, Any]:
"""Check Telegram service status"""
from config import settings
result = {
"service": "telegram",
"status": "unknown",
"timestamp": datetime.utcnow().isoformat()
}
try:
from services.telegram_service import telegram_service
if telegram_service.is_configured:
result["status"] = "healthy"
result["message"] = "Telegram service configured"
else:
result["status"] = "not_configured"
result["message"] = "Telegram credentials not configured"
except Exception as e:
result["status"] = "error"
result["error"] = str(e)
self.logger.error(f"Telegram service health check failed: {e}")
return result
def check_processed_registry(self) -> Dict[str, Any]:
"""Check processed files registry"""
result = {
"service": "processed_registry",
"status": "unknown",
"timestamp": datetime.utcnow().isoformat()
}
try:
from storage.processed_registry import processed_registry
# Try to load registry
processed_registry.load()
result["status"] = "healthy"
result["registry_path"] = str(processed_registry.registry_path)
# Check if registry file is writable
registry_file = Path(processed_registry.registry_path)
if registry_file.exists():
result["registry_exists"] = True
result["registry_writable"] = registry_file.is_file() and os.access(registry_file, os.W_OK)
else:
result["registry_exists"] = False
except Exception as e:
result["status"] = "unhealthy"
result["error"] = str(e)
self.logger.error(f"Processed registry health check failed: {e}")
return result
def check_disk_space(self) -> Dict[str, Any]:
"""Check available disk space"""
result = {
"service": "disk_space",
"status": "unknown",
"timestamp": datetime.utcnow().isoformat()
}
try:
import shutil
# Check main directory
usage = shutil.disk_usage(Path(__file__).parent.parent)
total_gb = usage.total / (1024**3)
free_gb = usage.free / (1024**3)
used_percent = (usage.used / usage.total) * 100
result["status"] = "healthy"
result["total_gb"] = round(total_gb, 2)
result["free_gb"] = round(free_gb, 2)
result["used_percent"] = round(used_percent, 2)
# Warning if low disk space
if free_gb < 1: # Less than 1GB
result["status"] = "warning"
result["message"] = "Low disk space"
elif free_gb < 5: # Less than 5GB
result["status"] = "degraded"
result["message"] = "Disk space running low"
except Exception as e:
result["status"] = "error"
result["error"] = str(e)
self.logger.error(f"Disk space health check failed: {e}")
return result
def check_configuration(self) -> Dict[str, Any]:
"""Check configuration validity"""
from config import settings
result = {
"service": "configuration",
"status": "unknown",
"timestamp": datetime.utcnow().isoformat()
}
try:
warnings = []
# Check for warnings
if not settings.has_webdav_config:
warnings.append("WebDAV not configured")
if not settings.has_ai_config:
warnings.append("AI providers not configured")
if not settings.telegram_configured:
warnings.append("Telegram not configured")
if settings.DASHBOARD_SECRET_KEY == "":
warnings.append("Dashboard secret key not set")
if settings.DASHBOARD_SECRET_KEY == "dashboard-secret-key-change-in-production":
warnings.append("Using default dashboard secret")
result["status"] = "healthy" if not warnings else "warning"
result["warnings"] = warnings
result["environment"] = settings.environment_type
except Exception as e:
result["status"] = "error"
result["error"] = str(e)
self.logger.error(f"Configuration health check failed: {e}")
return result
def run_full_health_check(self) -> Dict[str, Any]:
"""Run all health checks and return comprehensive status"""
checks = [
("configuration", self.check_configuration),
("webdav", self.check_webdav_connection),
("ai_providers", self.check_ai_providers),
("vram_manager", self.check_vram_manager),
("telegram", self.check_telegram_service),
("processed_registry", self.check_processed_registry),
("disk_space", self.check_disk_space)
]
results = {}
overall_status = "healthy"
for check_name, check_func in checks:
try:
result = check_func()
results[check_name] = result
# Track overall status
if result["status"] in ["unhealthy", "error"]:
overall_status = "unhealthy"
elif result["status"] in ["warning", "degraded"] and overall_status == "healthy":
overall_status = "warning"
except Exception as e:
results[check_name] = {
"service": check_name,
"status": "error",
"error": str(e),
"timestamp": datetime.utcnow().isoformat()
}
overall_status = "unhealthy"
self.logger.error(f"Health check {check_name} failed: {e}")
return {
"overall_status": overall_status,
"timestamp": datetime.utcnow().isoformat(),
"checks": results,
"summary": {
"total_checks": len(checks),
"healthy": sum(1 for r in results.values() if r["status"] == "healthy"),
"warning": sum(1 for r in results.values() if r["status"] == "warning"),
"unhealthy": sum(1 for r in results.values() if r["status"] == "unhealthy")
}
}
# Convenience function for CLI usage
def get_health_status() -> Dict[str, Any]:
"""Get comprehensive health status"""
checker = HealthChecker()
return checker.run_full_health_check()
if __name__ == "__main__":
# CLI usage: python core/health_check.py
import sys
import os
health = get_health_status()
print(json.dumps(health, indent=2))
# Exit with appropriate code
if health["overall_status"] == "healthy":
sys.exit(0)
elif health["overall_status"] == "warning":
sys.exit(1)
else:
sys.exit(2)

622
core/process_manager.py Normal file
View File

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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/)

View File

@@ -1,7 +0,0 @@
"""
Document generation package for CBCFacil
"""
from .generators import DocumentGenerator
__all__ = ['DocumentGenerator']

View File

@@ -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

826
main.py
View File

@@ -1,64 +1,83 @@
#!/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 (GLM-4.7)
- Generación de PDF
- Notificaciones Telegram
"""
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 +86,413 @@ 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."""
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"}
pending_files = [
f for f in downloads_dir.iterdir()
if f.is_file() and f.suffix.lower() in audio_extensions and not f.name.startswith(".")
]
if not pending_files:
logger.debug("No pending audio files to process")
return
logger.info(
f"Found {len(pending_files)} pending audio files",
extra={"count": len(pending_files)},
)
for file_path in pending_files:
logger.info(f"Processing pending file: {file_path.name}")
self.queue_file_for_processing(file_path)
def stop(self) -> None:
"""Detiene el servicio."""
self._running = False
if self.watcher:
self.watcher.stop()
def main():
"""Main entry point"""
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())

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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()

View File

@@ -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
View 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

View File

@@ -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"]

View File

@@ -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'
]

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -0,0 +1,158 @@
"""AI Summary Service using Anthropic/Z.AI API (GLM)."""
import logging
import os
from typing import Optional
import requests
logger = logging.getLogger(__name__)
class AISummaryService:
"""Service for AI-powered text summarization using Anthropic/Z.AI API."""
def __init__(
self,
auth_token: Optional[str] = None,
base_url: Optional[str] = None,
model: Optional[str] = None,
timeout: int = 120,
) -> None:
"""Initialize the AI Summary Service.
Args:
auth_token: API authentication token. Defaults to ANTHROPIC_AUTH_TOKEN env var.
base_url: API base URL. Defaults to ANTHROPIC_BASE_URL env var.
model: Model identifier. Defaults to ANTHROPIC_MODEL env var.
timeout: Request timeout in seconds. Defaults to 120.
"""
self.auth_token = auth_token or os.getenv("ANTHROPIC_AUTH_TOKEN")
# Normalize base_url: remove /anthropic suffix if present
raw_base_url = base_url or os.getenv("ANTHROPIC_BASE_URL")
if raw_base_url and raw_base_url.endswith("/anthropic"):
raw_base_url = raw_base_url[:-len("/anthropic")]
self.base_url = raw_base_url
self.model = model or os.getenv("ANTHROPIC_MODEL", "glm-4")
self.timeout = timeout
self._available = bool(self.auth_token and self.base_url)
if self._available:
logger.info(
"AISummaryService initialized with model=%s, base_url=%s",
self.model,
self.base_url,
)
else:
logger.debug("AISummaryService: no configuration found, running in silent mode")
@property
def is_available(self) -> bool:
"""Check if the service is properly configured."""
return self._available
def summarize(self, text: str, prompt_template: Optional[str] = None) -> str:
"""Summarize the given text using the AI API.
Args:
text: The text to summarize.
prompt_template: Optional custom prompt template. If None, uses default.
Returns:
The summarized text.
Raises:
RuntimeError: If the service is not configured.
requests.RequestException: If the API call fails.
"""
if not self._available:
logger.debug("AISummaryService not configured, returning original text")
return text
default_prompt = "Resume el siguiente texto de manera clara y concisa:"
prompt = prompt_template.format(text=text) if prompt_template else f"{default_prompt}\n\n{text}"
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2048,
"temperature": 0.7,
}
headers = {
"Authorization": f"Bearer {self.auth_token}",
"Content-Type": "application/json",
}
try:
logger.debug("Calling AI API for summarization (text length: %d)", len(text))
response = requests.post(
f"{self.base_url}/v1/chat/completions",
json=payload,
headers=headers,
timeout=self.timeout,
)
response.raise_for_status()
result = response.json()
summary = result.get("choices", [{}])[0].get("message", {}).get("content", "")
logger.info("Summarization completed successfully (output length: %d)", len(summary))
return summary
except requests.Timeout:
logger.error("AI API request timed out after %d seconds", self.timeout)
raise requests.RequestException(f"Request timed out after {self.timeout}s") from None
except requests.RequestException as e:
logger.error("AI API request failed: %s", str(e))
raise
def fix_latex(self, text: str) -> str:
"""Fix LaTeX formatting issues in the given text.
Args:
text: The text containing LaTeX to fix.
Returns:
The text with corrected LaTeX formatting.
"""
if not self._available:
logger.debug("AISummaryService not configured, returning original text")
return text
prompt = (
"Corrige los errores de formato LaTeX en el siguiente texto. "
"Mantén el contenido pero corrige la sintaxis de LaTeX:\n\n"
f"{text}"
)
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 4096,
"temperature": 0.3,
}
headers = {
"Authorization": f"Bearer {self.auth_token}",
"Content-Type": "application/json",
}
try:
logger.debug("Calling AI API for LaTeX fixing (text length: %d)", len(text))
response = requests.post(
f"{self.base_url}/v1/chat/completions",
json=payload,
headers=headers,
timeout=self.timeout,
)
response.raise_for_status()
result = response.json()
fixed = result.get("choices", [{}])[0].get("message", {}).get("content", "")
logger.info("LaTeX fixing completed successfully")
return fixed
except requests.RequestException as e:
logger.error("LaTeX fixing failed: %s", str(e))
return text

View File

@@ -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()

View File

@@ -1,137 +0,0 @@
"""
Performance metrics collector for CBCFacil
"""
import time
import threading
import psutil
import logging
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
from contextlib import contextmanager
class MetricsCollector:
"""Collect and aggregate performance metrics"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self._start_time = time.time()
self._request_count = 0
self._error_count = 0
self._total_latency = 0.0
self._latencies = []
self._lock = threading.Lock()
self._process = psutil.Process()
def record_request(self, latency: float, success: bool = True) -> None:
"""Record a request with latency"""
with self._lock:
self._request_count += 1
self._total_latency += latency
self._latencies.append(latency)
# Keep only last 1000 latencies for memory efficiency
if len(self._latencies) > 1000:
self._latencies = self._latencies[-1000:]
if not success:
self._error_count += 1
def get_latency_percentiles(self) -> Dict[str, float]:
"""Calculate latency percentiles"""
with self._lock:
if not self._latencies:
return {"p50": 0, "p95": 0, "p99": 0}
sorted_latencies = sorted(self._latencies)
n = len(sorted_latencies)
return {
"p50": sorted_latencies[int(n * 0.50)],
"p95": sorted_latencies[int(n * 0.95)],
"p99": sorted_latencies[int(n * 0.99)]
}
def get_system_metrics(self) -> Dict[str, Any]:
"""Get system resource metrics"""
try:
memory = self._process.memory_info()
cpu_percent = self._process.cpu_percent(interval=0.1)
return {
"cpu_percent": cpu_percent,
"memory_rss_mb": memory.rss / 1024 / 1024,
"memory_vms_mb": memory.vms / 1024 / 1024,
"thread_count": self._process.num_threads(),
"open_files": self._process.open_files(),
}
except Exception as e:
self.logger.warning(f"Error getting system metrics: {e}")
return {}
def get_summary(self) -> Dict[str, Any]:
"""Get metrics summary"""
with self._lock:
uptime = time.time() - self._start_time
latency_pcts = self.get_latency_percentiles()
return {
"uptime_seconds": round(uptime, 2),
"total_requests": self._request_count,
"error_count": self._error_count,
"error_rate": round(self._error_count / max(1, self._request_count) * 100, 2),
"requests_per_second": round(self._request_count / max(1, uptime), 2),
"average_latency_ms": round(self._total_latency / max(1, self._request_count) * 1000, 2),
"latency_p50_ms": round(latency_pcts["p50"] * 1000, 2),
"latency_p95_ms": round(latency_pcts["p95"] * 1000, 2),
"latency_p99_ms": round(latency_pcts["p99"] * 1000, 2),
}
def reset(self) -> None:
"""Reset metrics"""
with self._lock:
self._request_count = 0
self._error_count = 0
self._total_latency = 0.0
self._latencies = []
self._start_time = time.time()
class LatencyTracker:
"""Context manager for tracking operation latency"""
def __init__(self, collector: MetricsCollector, operation: str):
self.collector = collector
self.operation = operation
self.start_time: Optional[float] = None
self.success = True
def __enter__(self):
self.start_time = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
latency = time.time() - self.start_time
success = exc_type is None
self.collector.record_request(latency, success)
return False # Don't suppress exceptions
# Global metrics collector
metrics_collector = MetricsCollector()
@contextmanager
def track_latency(operation: str = "unknown"):
"""Convenience function for latency tracking"""
with LatencyTracker(metrics_collector, operation):
yield
def get_performance_report() -> Dict[str, Any]:
"""Generate comprehensive performance report"""
return {
"metrics": metrics_collector.get_summary(),
"system": metrics_collector.get_system_metrics(),
"timestamp": datetime.utcnow().isoformat()
}

270
services/pdf_generator.py Normal file
View File

@@ -0,0 +1,270 @@
"""
Generador de PDFs desde texto y markdown.
Utiliza reportlab para la generación de PDFs con soporte UTF-8.
"""
import logging
from pathlib import Path
from typing import Union
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
logger = logging.getLogger(__name__)
class PDFGenerator:
"""Generador de PDFs desde texto plano o markdown."""
def __init__(self) -> None:
"""Inicializa el generador de PDFs."""
self._styles = getSampleStyleSheet()
self._setup_styles()
logger.info("PDFGenerator inicializado")
def _setup_styles(self) -> None:
"""Configura los estilos personalizados para el documento."""
self._styles.add(
ParagraphStyle(
name="CustomNormal",
parent=self._styles["Normal"],
fontSize=11,
leading=14,
spaceAfter=6,
)
)
self._styles.add(
ParagraphStyle(
name="CustomHeading1",
parent=self._styles["Heading1"],
fontSize=18,
leading=22,
spaceAfter=12,
)
)
self._styles.add(
ParagraphStyle(
name="CustomHeading2",
parent=self._styles["Heading2"],
fontSize=14,
leading=18,
spaceAfter=10,
)
)
def _escape_xml(self, text: str) -> str:
"""Escapa caracteres especiales para XML/HTML."""
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br/>")
)
def _parse_markdown_basic(self, markdown: str) -> list[Paragraph]:
"""
Convierte markdown básico a una lista de Paragraphs de reportlab.
Maneja: encabezados, negritas, italicas, lineas horizontales,
y saltos de linea.
"""
elements: list[Paragraph] = []
lines = markdown.split("\n")
in_list = False
for line in lines:
line = line.strip()
if not line:
elements.append(Spacer(1, 0.3 * cm))
continue
# Encabezados
if line.startswith("### "):
text = self._escape_xml(line[4:])
elements.append(
Paragraph(f"<b>{text}</b>", self._styles["CustomHeading2"])
)
elif line.startswith("## "):
text = self._escape_xml(line[3:])
elements.append(
Paragraph(f"<b>{text}</b>", self._styles["CustomHeading1"])
)
elif line.startswith("# "):
text = self._escape_xml(line[2:])
elements.append(
Paragraph(f"<b><i>{text}</i></b>", self._styles["CustomHeading1"])
)
# Línea horizontal
elif line == "---" or line == "***":
elements.append(Spacer(1, 0.2 * cm))
# Lista con guiones
elif line.startswith("- ") or line.startswith("* "):
text = self._escape_xml(line[2:])
text = f"{self._format_inline_markdown(text)}"
elements.append(Paragraph(text, self._styles["CustomNormal"]))
# Lista numerada
elif line[0].isdigit() and ". " in line:
idx = line.index(". ")
text = self._escape_xml(line[idx + 2 :])
text = self._format_inline_markdown(text)
elements.append(Paragraph(text, self._styles["CustomNormal"]))
# Párrafo normal
else:
text = self._escape_xml(line)
text = self._format_inline_markdown(text)
elements.append(Paragraph(text, self._styles["CustomNormal"]))
return elements
def _format_inline_markdown(self, text: str) -> str:
"""Convierte formato inline de markdown a HTML."""
# Negritas: **texto** -> <b>texto</b>
while "**" in text:
start = text.find("**")
end = text.find("**", start + 2)
if end == -1:
break
text = (
text[:start]
+ f"<b>{text[start+2:end]}</b>"
+ text[end + 2 :]
)
# Italicas: *texto* -> <i>texto</i>
while "*" in text:
start = text.find("*")
end = text.find("*", start + 1)
if end == -1:
break
text = (
text[:start]
+ f"<i>{text[start+1:end]}</i>"
+ text[end + 1 :]
)
return text
def markdown_to_pdf(self, markdown_text: str, output_path: Path) -> Path:
"""
Convierte markdown a PDF.
Args:
markdown_text: Contenido en formato markdown.
output_path: Ruta donde se guardará el PDF.
Returns:
Path: Ruta del archivo PDF generado.
Raises:
ValueError: Si el contenido está vacío.
IOError: Si hay error al escribir el archivo.
"""
if not markdown_text or not markdown_text.strip():
logger.warning("markdown_to_pdf llamado con contenido vacío")
raise ValueError("El contenido markdown no puede estar vacío")
logger.info(
"Convirtiendo markdown a PDF",
extra={
"content_length": len(markdown_text),
"output_path": str(output_path),
},
)
try:
# Crear documento
doc = SimpleDocTemplate(
str(output_path),
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
)
# Convertir markdown a elementos
elements = self._parse_markdown_basic(markdown_text)
# Generar PDF
doc.build(elements)
logger.info(
"PDF generado exitosamente",
extra={"output_path": str(output_path), "pages": "unknown"},
)
return output_path
except Exception as e:
logger.error(f"Error al generar PDF desde markdown: {e}")
raise IOError(f"Error al generar PDF: {e}") from e
def text_to_pdf(self, text: str, output_path: Path) -> Path:
"""
Convierte texto plano a PDF.
Args:
text: Contenido de texto plano.
output_path: Ruta donde se guardará el PDF.
Returns:
Path: Ruta del archivo PDF generado.
Raises:
ValueError: Si el contenido está vacío.
IOError: Si hay error al escribir el archivo.
"""
if not text or not text.strip():
logger.warning("text_to_pdf llamado con contenido vacío")
raise ValueError("El contenido de texto no puede estar vacío")
logger.info(
"Convirtiendo texto a PDF",
extra={
"content_length": len(text),
"output_path": str(output_path),
},
)
try:
# Crear documento
doc = SimpleDocTemplate(
str(output_path),
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
)
# Convertir texto a párrafos (uno por línea)
elements: list[Union[Paragraph, Spacer]] = []
lines = text.split("\n")
for line in lines:
line = line.strip()
if not line:
elements.append(Spacer(1, 0.3 * cm))
else:
escaped = self._escape_xml(line)
elements.append(Paragraph(escaped, self._styles["CustomNormal"]))
# Generar PDF
doc.build(elements)
logger.info(
"PDF generado exitosamente",
extra={"output_path": str(output_path), "pages": "unknown"},
)
return output_path
except Exception as e:
logger.error(f"Error al generar PDF desde texto: {e}")
raise IOError(f"Error al generar PDF: {e}") from e
# Instancia global del generador
pdf_generator = PDFGenerator()

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
safe_error = error.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
safe_path = pdf_path.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
files_text = ""
if txt_path:
safe_txt = txt_path.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
files_text += f"\n📝 <code>{safe_txt}</code>"
if md_path:
safe_md = md_path.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
files_text += f"\n📋 <code>{safe_md}</code>"
if pdf_path:
safe_pdf = pdf_path.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)

View File

@@ -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()

View File

@@ -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
View File

@@ -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()

View File

@@ -1,7 +0,0 @@
"""
Storage package for CBCFacil
"""
from .processed_registry import ProcessedRegistry
__all__ = ['ProcessedRegistry']

View File

@@ -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()

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,3 +0,0 @@
"""
Test package for CBCFacil
"""

View File

@@ -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', '')

View File

@@ -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
View File

@@ -0,0 +1,4 @@
"""Export de watchers."""
from .folder_watcher import FolderWatcher, RemoteFolderWatcher
__all__ = ["FolderWatcher", "RemoteFolderWatcher"]

218
watchers/folder_watcher.py Normal file
View File

@@ -0,0 +1,218 @@
"""
Watcher de carpeta local.
Monitorea una carpeta por archivos nuevos y los procesa.
"""
import logging
import time
from pathlib import Path
from typing import Callable, Optional
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
from config import settings
from services import WebDAVService
class FileHandler(FileSystemEventHandler):
"""Manejador de eventos del sistema de archivos."""
def __init__(
self,
on_new_file: Callable[[Path], None],
logger: Optional[logging.Logger] = None,
) -> None:
super().__init__()
self.on_new_file = on_new_file
self.logger = logger or logging.getLogger(__name__)
def on_created(self, event: FileCreatedEvent) -> None:
"""Se llama cuando se crea un nuevo archivo."""
if event.is_directory:
return
file_path = Path(event.src_path)
self.logger.info(f"New file detected: {file_path}")
# Ignorar archivos temporales
if file_path.suffix in [".tmp", ".part", ".crdownload"]:
return
# Ignorar archivos ocultos
if file_path.name.startswith("."):
return
# Esperar a que el archivo esté listo
time.sleep(1)
try:
self.on_new_file(file_path)
except Exception as e:
self.logger.error(f"Error processing file {file_path}: {e}")
class FolderWatcher:
"""Monitor de carpeta local."""
def __init__(
self,
watch_path: Optional[Path] = None,
on_new_file: Optional[Callable[[Path], None]] = None,
) -> None:
self.logger = logging.getLogger(__name__)
self.watch_path = watch_path or settings.DOWNLOADS_DIR
self.on_new_file_callback = on_new_file
self._observer: Optional[Observer] = None
self._running = False
self._processed_files: set[str] = set()
# Asegurar que la carpeta existe
self.watch_path.mkdir(parents=True, exist_ok=True)
def set_callback(self, callback: Callable[[Path], None]) -> None:
"""Establece el callback para nuevos archivos."""
self.on_new_file_callback = callback
def start(self) -> None:
"""Inicia el watcher."""
if self._running:
self.logger.warning("Watcher already running")
return
self.logger.info(f"Starting folder watcher on: {self.watch_path}")
event_handler = FileHandler(
on_new_file=self._handle_new_file,
logger=self.logger,
)
self._observer = Observer()
self._observer.schedule(event_handler, str(self.watch_path), recursive=False)
self._observer.start()
self._running = True
self.logger.info("Folder watcher started")
def stop(self) -> None:
"""Detiene el watcher."""
if not self._running:
return
self.logger.info("Stopping folder watcher")
if self._observer:
self._observer.stop()
self._observer.join()
self._observer = None
self._running = False
self.logger.info("Folder watcher stopped")
def _handle_new_file(self, file_path: Path) -> None:
"""Maneja un nuevo archivo detectado."""
file_key = str(file_path)
# Evitar procesar el mismo archivo dos veces
if file_key in self._processed_files:
return
self._processed_files.add(file_key)
self.logger.info(f"Processing new file: {file_path}")
if self.on_new_file_callback:
self.on_new_file_callback(file_path)
def get_status(self) -> dict:
"""Obtiene el estado del watcher."""
return {
"running": self._running,
"watch_path": str(self.watch_path),
"processed_files_count": len(self._processed_files),
}
class RemoteFolderWatcher:
"""Watcher que descarga archivos desde Nextcloud."""
def __init__(
self,
webdav_service: WebDAVService,
local_path: Optional[Path] = None,
remote_path: Optional[str] = None,
) -> None:
self.logger = logging.getLogger(__name__)
self.webdav = webdav_service
self.local_path = local_path or settings.DOWNLOADS_DIR
self.remote_path = remote_path or settings.WATCHED_REMOTE_PATH
self._running = False
self._last_checked_files: set[str] = set()
self._on_download: Optional[Callable[[Path], None]] = None
# Asegurar que la carpeta local existe
self.local_path.mkdir(parents=True, exist_ok=True)
def set_callback(self, callback: Callable[[Path], None]) -> None:
"""Establece el callback para archivos descargados."""
self._on_download = callback
def start(self) -> None:
"""Inicia el polling de archivos remotos."""
if self._running:
self.logger.warning("Remote watcher already running")
return
self._running = True
self.logger.info(f"Starting remote folder watcher: {self.remote_path} -> {self.local_path}")
# Primer escaneo
self._check_for_new_files()
def stop(self) -> None:
"""Detiene el watcher."""
self._running = False
self.logger.info("Remote folder watcher stopped")
def check_now(self) -> None:
"""Fuerza una verificación inmediata."""
self._check_for_new_files()
def _check_for_new_files(self) -> None:
"""Verifica si hay nuevos archivos en Nextcloud."""
if not self._running:
return
try:
files = self.webdav.list_files(self.remote_path)
current_files = set(files) - self._last_checked_files
if current_files:
self.logger.info(f"Found {len(current_files)} new files")
for filename in current_files:
if filename.strip(): # Ignorar nombres vacíos
self._download_file(filename)
self._last_checked_files = set(files)
except Exception as e:
self.logger.error(f"Error checking remote files: {e}")
def _download_file(self, filename: str) -> None:
"""Descarga un archivo individual."""
remote_path = f"{self.remote_path}/{filename}"
local_path = self.local_path / filename
self.logger.info(f"Downloading: {remote_path}")
if self.webdav.download_file(remote_path, local_path):
if self._on_download:
self._on_download(local_path)
else:
self.logger.error(f"Failed to download: {filename}")
def get_status(self) -> dict:
"""Obtiene el estado del watcher."""
return {
"running": self._running,
"remote_path": self.remote_path,
"local_path": str(self.local_path),
"last_checked_files": len(self._last_checked_files),
}