Compare commits

...

4 Commits

Author SHA1 Message Date
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
25 changed files with 8312 additions and 527 deletions

View File

@@ -40,6 +40,14 @@ GEMINI_CLI_PATH=/path/to/gemini # or leave empty
TELEGRAM_TOKEN=your_telegram_bot_token TELEGRAM_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_telegram_chat_id TELEGRAM_CHAT_ID=your_telegram_chat_id
# =============================================================================
# Notion Integration (Optional - for automatic PDF uploads)
# =============================================================================
# Get your token from: https://developers.notion.com/docs/create-a-notion-integration
NOTION_API=ntn_YOUR_NOTION_INTEGRATION_TOKEN_HERE
# Get your database ID from the database URL in Notion
NOTION_DATABASE_ID=your_database_id_here
# ============================================================================= # =============================================================================
# Dashboard Configuration (Required for production) # Dashboard Configuration (Required for production)
# ============================================================================= # =============================================================================

14
.gitignore vendored
View File

@@ -71,3 +71,17 @@ old/
imperio/ imperio/
check_models.py check_models.py
compare_configs.py compare_configs.py
# LaTeX auxiliary files
*.aux
*.toc
*.out
*.synctex.gz
*.fls
*.fdb_latexmk
# Generated PDFs (keep source .tex files)
*.pdf
# macOS specific
mac/

View File

@@ -1,19 +1,25 @@
""" """
Centralized configuration management for CBCFacil Centralized configuration management for CBCFacil
""" """
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional, Set, Union from typing import Optional, Set, Union
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class ConfigurationError(Exception): class ConfigurationError(Exception):
"""Raised when configuration is invalid""" """Raised when configuration is invalid"""
pass pass
class Settings: class Settings:
"""Application settings loaded from environment variables""" """Application settings loaded from environment variables"""
# Application # Application
APP_NAME: str = "CBCFacil" APP_NAME: str = "CBCFacil"
APP_VERSION: str = "8.0" APP_VERSION: str = "8.0"
@@ -44,7 +50,9 @@ class Settings:
POLL_INTERVAL: int = int(os.getenv("POLL_INTERVAL", "5")) POLL_INTERVAL: int = int(os.getenv("POLL_INTERVAL", "5"))
HTTP_TIMEOUT: int = int(os.getenv("HTTP_TIMEOUT", "30")) HTTP_TIMEOUT: int = int(os.getenv("HTTP_TIMEOUT", "30"))
WEBDAV_MAX_RETRIES: int = int(os.getenv("WEBDAV_MAX_RETRIES", "3")) 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 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_LENGTH: int = int(os.getenv("MAX_FILENAME_LENGTH", "80"))
MAX_FILENAME_BASE_LENGTH: int = int(os.getenv("MAX_FILENAME_BASE_LENGTH", "40")) 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")) MAX_FILENAME_TOPICS_LENGTH: int = int(os.getenv("MAX_FILENAME_TOPICS_LENGTH", "20"))
@@ -57,7 +65,13 @@ class Settings:
# AI Providers # AI Providers
ZAI_BASE_URL: str = os.getenv("ZAI_BASE_URL", "https://api.z.ai/api/anthropic") 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_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", "") ZAI_AUTH_TOKEN: Optional[str] = os.getenv("ANTHROPIC_AUTH_TOKEN") or os.getenv(
"ZAI_AUTH_TOKEN", ""
)
# Notion Integration
NOTION_API_TOKEN: Optional[str] = os.getenv("NOTION_API")
NOTION_DATABASE_ID: Optional[str] = os.getenv("NOTION_DATABASE_ID")
# Gemini # Gemini
GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY") GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
@@ -76,13 +90,25 @@ class Settings:
CPU_COUNT: int = os.cpu_count() or 1 CPU_COUNT: int = os.cpu_count() or 1
PDF_MAX_PAGES_PER_CHUNK: int = int(os.getenv("PDF_MAX_PAGES_PER_CHUNK", "2")) 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_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_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_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_TROCR_MAX_BATCH: int = int(
PDF_TESSERACT_THREADS: int = int(os.getenv("PDF_TESSERACT_THREADS", str(max(1, min(2, max(1, CPU_COUNT // 3)))))) os.getenv("PDF_TROCR_MAX_BATCH", str(PDF_BATCH_SIZE))
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_TESSERACT_THREADS: int = int(
PDF_TEXT_DETECTION_MIN_AVG_CHARS: int = int(os.getenv("PDF_TEXT_DETECTION_MIN_AVG_CHARS", "120")) 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 handling
ERROR_THROTTLE_SECONDS: int = int(os.getenv("ERROR_THROTTLE_SECONDS", "600")) ERROR_THROTTLE_SECONDS: int = int(os.getenv("ERROR_THROTTLE_SECONDS", "600"))
@@ -90,8 +116,10 @@ class Settings:
# GPU/VRAM Management # GPU/VRAM Management
MODEL_TIMEOUT_SECONDS: int = int(os.getenv("MODEL_TIMEOUT_SECONDS", "300")) MODEL_TIMEOUT_SECONDS: int = int(os.getenv("MODEL_TIMEOUT_SECONDS", "300"))
CUDA_VISIBLE_DEVICES: str = os.getenv("CUDA_VISIBLE_DEVICES", "all") 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") PYTORCH_CUDA_ALLOC_CONF: str = os.getenv(
"PYTORCH_CUDA_ALLOC_CONF", "max_split_size_mb:512"
)
# GPU Detection (auto, nvidia, amd, cpu) # GPU Detection (auto, nvidia, amd, cpu)
GPU_PREFERENCE: str = os.getenv("GPU_PREFERENCE", "auto") GPU_PREFERENCE: str = os.getenv("GPU_PREFERENCE", "auto")
# AMD ROCm HSA override for RX 6000 series (gfx1030) # AMD ROCm HSA override for RX 6000 series (gfx1030)
@@ -110,56 +138,77 @@ class Settings:
OMP_NUM_THREADS: int = int(os.getenv("OMP_NUM_THREADS", "4")) OMP_NUM_THREADS: int = int(os.getenv("OMP_NUM_THREADS", "4"))
MKL_NUM_THREADS: int = int(os.getenv("MKL_NUM_THREADS", "4")) MKL_NUM_THREADS: int = int(os.getenv("MKL_NUM_THREADS", "4"))
# Parallel Processing Configuration
MAX_PARALLEL_UPLOADS: int = int(os.getenv("MAX_PARALLEL_UPLOADS", "4"))
MAX_PARALLEL_AI_REQUESTS: int = int(os.getenv("MAX_PARALLEL_AI_REQUESTS", "3"))
MAX_PARALLEL_PROCESSING: int = int(os.getenv("MAX_PARALLEL_PROCESSING", "2"))
PARALLEL_AI_STRATEGY: str = os.getenv("PARALLEL_AI_STRATEGY", "race") # race, consensus, majority
BACKGROUND_NOTION_UPLOADS: bool = os.getenv("BACKGROUND_NOTION_UPLOADS", "true").lower() == "true"
# ======================================================================== # ========================================================================
# PROPERTIES WITH VALIDATION # PROPERTIES WITH VALIDATION
# ======================================================================== # ========================================================================
@property @property
def is_production(self) -> bool: def is_production(self) -> bool:
"""Check if running in production mode""" """Check if running in production mode"""
return not self.DEBUG return not self.DEBUG
@property @property
def has_webdav_config(self) -> bool: def has_webdav_config(self) -> bool:
"""Check if WebDAV credentials are configured""" """Check if WebDAV credentials are configured"""
return all([self.NEXTCLOUD_URL, self.NEXTCLOUD_USER, self.NEXTCLOUD_PASSWORD]) return all([self.NEXTCLOUD_URL, self.NEXTCLOUD_USER, self.NEXTCLOUD_PASSWORD])
@property @property
def has_ai_config(self) -> bool: def has_ai_config(self) -> bool:
"""Check if AI providers are configured""" """Check if AI providers are configured"""
return any([ return any(
self.ZAI_AUTH_TOKEN, [
self.GEMINI_API_KEY, self.ZAI_AUTH_TOKEN,
self.CLAUDE_CLI_PATH, self.GEMINI_API_KEY,
self.GEMINI_CLI_PATH self.CLAUDE_CLI_PATH,
]) self.GEMINI_CLI_PATH,
]
)
@property
def has_notion_config(self) -> bool:
"""Check if Notion is configured"""
return bool(self.NOTION_API_TOKEN and self.NOTION_DATABASE_ID)
@property @property
def processed_files_path(self) -> Path: def processed_files_path(self) -> Path:
"""Get the path to the processed files registry""" """Get the path to the processed files registry"""
return Path(os.getenv("PROCESSED_FILES_PATH", str(Path(self.LOCAL_STATE_DIR) / "processed_files.txt"))) return Path(
os.getenv(
"PROCESSED_FILES_PATH",
str(Path(self.LOCAL_STATE_DIR) / "processed_files.txt"),
)
)
@property @property
def nextcloud_url(self) -> str: def nextcloud_url(self) -> str:
"""Get Nextcloud URL with validation""" """Get Nextcloud URL with validation"""
if not self.NEXTCLOUD_URL and self.is_production: if not self.NEXTCLOUD_URL and self.is_production:
raise ConfigurationError("NEXTCLOUD_URL is required in production mode") raise ConfigurationError("NEXTCLOUD_URL is required in production mode")
return self.NEXTCLOUD_URL return self.NEXTCLOUD_URL
@property @property
def nextcloud_user(self) -> str: def nextcloud_user(self) -> str:
"""Get Nextcloud username with validation""" """Get Nextcloud username with validation"""
if not self.NEXTCLOUD_USER and self.is_production: if not self.NEXTCLOUD_USER and self.is_production:
raise ConfigurationError("NEXTCLOUD_USER is required in production mode") raise ConfigurationError("NEXTCLOUD_USER is required in production mode")
return self.NEXTCLOUD_USER return self.NEXTCLOUD_USER
@property @property
def nextcloud_password(self) -> str: def nextcloud_password(self) -> str:
"""Get Nextcloud password with validation""" """Get Nextcloud password with validation"""
if not self.NEXTCLOUD_PASSWORD and self.is_production: if not self.NEXTCLOUD_PASSWORD and self.is_production:
raise ConfigurationError("NEXTCLOUD_PASSWORD is required in production mode") raise ConfigurationError(
"NEXTCLOUD_PASSWORD is required in production mode"
)
return self.NEXTCLOUD_PASSWORD return self.NEXTCLOUD_PASSWORD
@property @property
def valid_webdav_config(self) -> bool: def valid_webdav_config(self) -> bool:
"""Validate WebDAV configuration completeness""" """Validate WebDAV configuration completeness"""
@@ -170,26 +219,27 @@ class Settings:
return True return True
except ConfigurationError: except ConfigurationError:
return False return False
@property @property
def telegram_configured(self) -> bool: def telegram_configured(self) -> bool:
"""Check if Telegram is properly configured""" """Check if Telegram is properly configured"""
return bool(self.TELEGRAM_TOKEN and self.TELEGRAM_CHAT_ID) return bool(self.TELEGRAM_TOKEN and self.TELEGRAM_CHAT_ID)
@property @property
def has_gpu_support(self) -> bool: def has_gpu_support(self) -> bool:
"""Check if GPU support is available""" """Check if GPU support is available"""
try: try:
import torch import torch
return torch.cuda.is_available() return torch.cuda.is_available()
except ImportError: except ImportError:
return False return False
@property @property
def environment_type(self) -> str: def environment_type(self) -> str:
"""Get environment type as string""" """Get environment type as string"""
return "production" if self.is_production else "development" return "production" if self.is_production else "development"
@property @property
def config_summary(self) -> dict: def config_summary(self) -> dict:
"""Get configuration summary for logging""" """Get configuration summary for logging"""
@@ -203,7 +253,7 @@ class Settings:
"telegram_configured": self.telegram_configured, "telegram_configured": self.telegram_configured,
"gpu_support": self.has_gpu_support, "gpu_support": self.has_gpu_support,
"cpu_count": self.CPU_COUNT, "cpu_count": self.CPU_COUNT,
"poll_interval": self.POLL_INTERVAL "poll_interval": self.POLL_INTERVAL,
} }

126
create_notion_database.py Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Script para crear una nueva base de datos de Notion y compartirla automáticamente
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from config import settings
from notion_client import Client
def main():
print("\n" + "=" * 70)
print("🛠️ CREAR BASE DE DATOS DE NOTION PARA CBCFACIL")
print("=" * 70 + "\n")
token = settings.NOTION_API_TOKEN
if not token:
print("❌ Token no configurado en .env")
return
client = Client(auth=token)
# Primero, buscar una página donde crear la database
print("🔍 Buscando páginas accesibles...\n")
results = client.search(page_size=100)
pages = [p for p in results.get("results", []) if p.get("object") == "page"]
if not pages:
print("❌ No tienes páginas accesibles.")
print("\n📋 SOLUCIÓN:")
print("1. Ve a Notion y crea una nueva página")
print("2. En esa página, click en 'Share'")
print("3. Busca y agrega tu integración")
print("4. Ejecuta este script nuevamente\n")
return
# Mostrar páginas disponibles
print(f"✅ Encontradas {len(pages)} página(s) accesibles:\n")
for i, page in enumerate(pages[:10], 1):
page_id = page.get("id")
props = page.get("properties", {})
# Intentar obtener el título
title = "Sin título"
for prop_name, prop_data in props.items():
if prop_data.get("type") == "title":
title_list = prop_data.get("title", [])
if title_list:
title = title_list[0].get("plain_text", "Sin título")
break
print(f"{i}. {title[:50]}")
print(f" ID: {page_id}\n")
# Usar la primera página accesible
parent_page = pages[0]
parent_id = parent_page.get("id")
print("=" * 70)
print(f"📄 Voy a crear la base de datos dentro de la primera página")
print("=" * 70 + "\n")
try:
# Crear la base de datos
print("🚀 Creando base de datos 'CBCFacil - Documentos'...\n")
database = client.databases.create(
parent={"page_id": parent_id},
title=[
{
"type": "text",
"text": {"content": "CBCFacil - Documentos Procesados"},
}
],
properties={
"Name": {"title": {}},
"Status": {
"select": {
"options": [
{"name": "Procesado", "color": "green"},
{"name": "En Proceso", "color": "yellow"},
{"name": "Error", "color": "red"},
]
}
},
"Tipo": {
"select": {
"options": [
{"name": "AUDIO", "color": "purple"},
{"name": "PDF", "color": "orange"},
{"name": "TEXTO", "color": "gray"},
]
}
},
"Fecha": {"date": {}},
},
)
db_id = database["id"]
print("✅ ¡Base de datos creada exitosamente!")
print("=" * 70)
print(f"\n📊 Información de la base de datos:\n")
print(f" Nombre: CBCFacil - Documentos Procesados")
print(f" ID: {db_id}")
print(f" URL: https://notion.so/{db_id.replace('-', '')}")
print("\n=" * 70)
print("\n🎯 SIGUIENTE PASO:")
print("=" * 70)
print(f"\nActualiza tu archivo .env con:\n")
print(f"NOTION_DATABASE_ID={db_id}\n")
print("Luego ejecuta:")
print("python test_notion_integration.py\n")
print("=" * 70 + "\n")
except Exception as e:
print(f"❌ Error creando base de datos: {e}")
print("\nVerifica que la integración tenga permisos de escritura.\n")
if __name__ == "__main__":
main()

116
diagnose_notion.py Normal file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Script para diagnosticar la integración de Notion
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from config import settings
from notion_client import Client
def main():
print("\n" + "=" * 70)
print("🔍 DIAGNÓSTICO COMPLETO DE NOTION")
print("=" * 70 + "\n")
token = settings.NOTION_API_TOKEN
database_id = settings.NOTION_DATABASE_ID
print(f"Token: {token[:30]}..." if token else "❌ Token no configurado")
print(f"Database ID: {database_id}\n")
if not token:
print("❌ Configura NOTION_API en .env\n")
return
client = Client(auth=token)
# Test 1: Verificar que el token sea válido
print("📝 Test 1: Verificando token...")
try:
# Intentar buscar páginas (cualquiera)
results = client.search(query="", page_size=1)
print("✅ Token válido - la integración está activa\n")
# Ver si tiene acceso a alguna página
pages = results.get("results", [])
if pages:
print(f"✅ La integración tiene acceso a {len(pages)} página(s)")
for page in pages[:3]:
page_id = page.get("id", "N/A")
page_type = page.get("object", "N/A")
print(f" - {page_type}: {page_id}")
else:
print("⚠️ La integración NO tiene acceso a ninguna página aún")
print(" Esto es normal si acabas de crear la integración.\n")
except Exception as e:
print(f"❌ Error con el token: {e}\n")
return
# Test 2: Verificar acceso a la base de datos específica
print("\n📊 Test 2: Verificando acceso a la base de datos CBC...")
try:
database = client.databases.retrieve(database_id=database_id)
print("✅ ¡ÉXITO! La integración puede acceder a la base de datos\n")
title = database.get("title", [{}])[0].get("plain_text", "Sin título")
print(f" Título: {title}")
print(f" ID: {database['id']}")
print(f"\n Propiedades:")
for prop_name in database.get("properties", {}).keys():
print(f"{prop_name}")
print("\n" + "=" * 70)
print("✅ TODO CONFIGURADO CORRECTAMENTE")
print("=" * 70)
print("\n🚀 Ejecuta: python test_notion_integration.py\n")
except Exception as e:
error_msg = str(e)
print(f"❌ No se puede acceder a la base de datos")
print(f" Error: {error_msg}\n")
if "Could not find database" in error_msg:
print("=" * 70)
print("⚠️ ACCIÓN REQUERIDA: Compartir la base de datos")
print("=" * 70)
print("\n📋 PASOS DETALLADOS:\n")
print("1. Abre Notion en tu navegador")
print("\n2. Ve a tu base de datos 'CBC'")
print(f" Opción A: Usa este link directo:")
print(f" → https://www.notion.so/{database_id.replace('-', '')}")
print(f"\n Opción B: Busca 'CBC' en tu workspace")
print("\n3. En la página de la base de datos, busca el botón '...' ")
print(" (tres puntos) en la esquina SUPERIOR DERECHA")
print("\n4. En el menú que se abre, busca:")
print("'Connections' (en inglés)")
print("'Conexiones' (en español)")
print("'Connect to' (puede variar)")
print("\n5. Haz click y verás un menú de integraciones")
print("\n6. Busca tu integración en la lista")
print(" (Debería tener el nombre que le pusiste al crearla)")
print("\n7. Haz click en tu integración para activarla")
print("\n8. Confirma los permisos cuando te lo pida")
print("\n9. Deberías ver un mensaje confirmando la conexión")
print("\n10. ¡Listo! Vuelve a ejecutar:")
print(" python verify_notion_permissions.py\n")
print("=" * 70)
# Crear una página de prueba simple para verificar
print("\n💡 ALTERNATIVA: Crear una nueva página de prueba\n")
print("Si no encuentras la opción de conexiones en tu base de datos,")
print("puedes crear una página nueva y compartirla con la integración:\n")
print("1. Crea una nueva página en Notion")
print("2. En esa página, click en 'Share' (Compartir)")
print("3. Busca tu integración y agrégala")
print("4. Luego convierte esa página en una base de datos")
print("5. Usa el ID de esa nueva base de datos\n")
if __name__ == "__main__":
main()

View File

@@ -1,318 +1,669 @@
""" """
Document generation utilities Document generation utilities - LaTeX Academic Summary System
This module generates comprehensive academic summaries in LaTeX format
following the specifications in latex/resumen.md (the SINGLE SOURCE OF TRUTH).
Parallel Processing: Uses multiple agents for accelerated summary generation:
- AI Provider Racing: Multiple AI providers generate in parallel
- Parallel Format Conversion: PDF + DOCX generated simultaneously
- Background Notion Uploads: Non-blocking uploads to Notion
""" """
import logging import logging
import subprocess
import shutil
import re import re
import threading
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Tuple from typing import Dict, Any, Optional, Tuple, Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from core import FileProcessingError from core import FileProcessingError
from config import settings from config import settings
from services.ai import ai_provider_factory from services.ai import ai_provider_factory
from services.ai.prompt_manager import prompt_manager
def _sanitize_latex(latex_code: str) -> str:
"""
Pre-process LaTeX code to fix common errors before compilation.
This function applies automated fixes for known issues that AI models
frequently generate, reducing the need for fix_latex() iterations.
Currently handles:
- TikZ nodes with line breaks (\\\\) missing align=center
- Unbalanced environments (best effort)
"""
if not latex_code:
return latex_code
result = latex_code
# Fix TikZ nodes with \\\\ but missing align=center
# Pattern: \node[...] (name) {Text\\More};
# This is a common AI error - TikZ requires align=center for \\\\ in nodes
# We need to find \node commands and add align=center if they have \\\\ in content
# but don't already have align= in their options
def fix_tikz_node(match):
"""Fix a single TikZ node by adding align=center if needed"""
full_match = match.group(0)
options = match.group(1) # Content inside [...]
rest = match.group(2) # Everything after options
# Check if this node has \\\\ in its content (text between { })
# and doesn't already have align=
if "\\\\" in rest and "align=" not in options:
# Add align=center to the options
if options.strip():
new_options = options.rstrip() + ", align=center"
else:
new_options = "align=center"
return f"\\node[{new_options}]{rest}"
return full_match
# Match \node[options] followed by rest of the line
# Capture options and the rest separately
tikz_node_pattern = r"\\node\[([^\]]*)\]([^;]*;)"
result = re.sub(tikz_node_pattern, fix_tikz_node, result)
return result
class DocumentGenerator: class DocumentGenerator:
"""Generate documents from processed text""" """
Generates academic summary documents in LaTeX format.
def __init__(self): The system follows these principles:
1. latex/resumen.md is the SINGLE SOURCE OF TRUTH for prompt structure
2. Generates full LaTeX documents (not Markdown)
3. Compiles to PDF using pdflatex
4. Supports iterative fixing with AI if compilation fails
5. Supports progress notifications via callback
"""
def __init__(self, notification_callback: Optional[Callable[[str], None]] = None):
"""
Initialize DocumentGenerator.
Args:
notification_callback: Optional callback function for progress notifications
Takes a single string argument (message to send)
"""
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.ai_provider = ai_provider_factory.get_best_provider() self.ai_provider = ai_provider_factory.get_best_provider()
self.notification_callback = notification_callback
self.use_parallel = ai_provider_factory.use_parallel()
self.executor = ThreadPoolExecutor(max_workers=4)
def generate_summary(self, text: str, base_name: str) -> Tuple[bool, str, Dict[str, Any]]: # Ensure output directories exist
"""Generate unified summary""" settings.LOCAL_DOWNLOADS_PATH.mkdir(parents=True, exist_ok=True)
self.logger.info(f"Generating summary for {base_name}") settings.LOCAL_DOCX.mkdir(parents=True, exist_ok=True)
if self.use_parallel:
self.logger.info(
"🚀 Parallel processing enabled: Multiple AI providers available"
)
def _notify(self, message: str) -> None:
"""Send notification if callback is configured"""
if self.notification_callback:
try:
self.notification_callback(message)
except Exception as e:
self.logger.warning(f"Failed to send notification: {e}")
def _generate_with_parallel_provider(self, prompt: str, **kwargs) -> str:
"""
Generate content using multiple AI providers in parallel.
Races multiple providers and returns the first successful response,
or the best quality response if using consensus strategy.
"""
try:
parallel_provider = ai_provider_factory.get_parallel_provider(max_workers=4)
self.logger.info("🚀 Using parallel AI provider (race mode)")
result = parallel_provider.generate_parallel(
prompt=prompt,
strategy="race", # Use first successful response
timeout_ms=300000, # 5 minutes
**kwargs,
)
self.logger.info(
f"✅ Parallel generation complete: {result.selected_provider} selected, "
f"{result.total_duration_ms}ms"
)
return result.content
except Exception as e:
self.logger.warning(
f"⚠️ Parallel generation failed: {e}, falling back to single provider"
)
return self.ai_provider.generate_text(prompt, **kwargs)
def _convert_formats_parallel(
self, tex_path: Path, pdf_path: Optional[Path], base_name: str
) -> Optional[Path]:
"""
Convert to multiple formats in parallel (DOCX, optionally PDF).
If PDF is already compiled, only DOCX is generated.
Otherwise, both PDF and DOCX are generated in parallel.
"""
futures = {}
# Generate DOCX
if shutil.which("pandoc"):
futures["docx"] = self.executor.submit(
self._convert_tex_to_docx, tex_path, base_name
)
# Wait for DOCX completion
docx_path = None
if "docx" in futures:
try:
docx_path = futures["docx"].result(timeout=60)
if docx_path:
self.logger.info(f"✅ Parallel DOCX generated: {docx_path}")
except Exception as e:
self.logger.warning(f"⚠️ DOCX generation failed: {e}")
return docx_path
def _upload_to_notion_background(
self,
base_name: str,
summary: str,
pdf_path: Optional[Path],
metadata: Dict[str, Any],
):
"""Upload to Notion in background thread (non-blocking)."""
def upload_worker():
try:
from services.notion_service import notion_service
title = base_name.replace("_", " ").title()
notion_metadata = {
"file_type": "Audio",
"pdf_path": pdf_path or Path(""),
"add_status": False,
"use_as_page": False,
}
page_id = notion_service.create_page_with_summary(
title=title, summary=summary, metadata=notion_metadata
)
if page_id:
metadata["notion_uploaded"] = True
metadata["notion_page_id"] = page_id
self.logger.info(
f"✅ Background upload to Notion complete: {title}"
)
else:
self.logger.warning(f"⚠️ Background Notion upload failed: {title}")
except Exception as e:
self.logger.warning(f"❌ Background Notion upload error: {e}")
# Start background thread
thread = threading.Thread(target=upload_worker, daemon=True)
thread.start()
self.logger.info("🔄 Notion upload started in background")
def generate_summary(
self,
text: str,
base_name: str,
materia: str = "Economía",
bibliographic_text: Optional[str] = None,
class_number: Optional[int] = None,
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Generate comprehensive academic summary in LaTeX format.
Args:
text: The class transcription text
base_name: Base filename for output files
materia: Subject name (default: "Economía")
bibliographic_text: Optional supporting material from books/notes
class_number: Optional class number for header
Returns:
Tuple of (success, summary_text, metadata)
"""
self.logger.info(
f"🚀 Starting LaTeX academic summary generation for: {base_name}"
)
metadata = {
"filename": base_name,
"tex_path": "",
"pdf_path": "",
"markdown_path": "",
"docx_path": "",
"summary_snippet": "",
"notion_uploaded": False,
"notion_page_id": None,
"materia": materia,
}
try: try:
# Step 1: Generate Bullet Points (Chunking handled by provider or single prompt for now) # === STEP 1: Generate LaTeX content using AI ===
# Note: We use the main provider (Claude/Zai) for content generation self.logger.info(
self.logger.info("Generating bullet points...") "🧠 Sending request to AI Provider for LaTeX generation..."
bullet_prompt = f"""Analiza el siguiente texto y extrae entre 5 y 8 bullet points clave en español. )
self._notify("📝 Preparando prompt de resumen académico...")
REGLAS ESTRICTAS: prompt = prompt_manager.get_latex_summary_prompt(
1. Devuelve ÚNICAMENTE bullet points, cada línea iniciando con "- " transcription=text,
2. Cada bullet debe ser conciso (12-20 palabras) y resaltar datos, fechas, conceptos o conclusiones importantes materia=materia,
3. NO agregues introducciones, conclusiones ni texto explicativo bibliographic_text=bibliographic_text,
4. Concéntrate en los puntos más importantes del texto class_number=class_number,
5. Incluye fechas, datos específicos y nombres relevantes si los hay )
Texto: self._notify(
{text[:15000]}""" # Truncate to avoid context limits if necessary, though providers handle it differently "🧠 Enviando solicitud a la IA (esto puede tardar unos minutos)..."
)
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 # Use parallel provider if multiple AI providers are available
self.logger.info("Generating unified summary...") if self.use_parallel:
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. raw_response = self._generate_with_parallel_provider(prompt)
else:
raw_response = self.ai_provider.generate_text(prompt)
REQUISITOS ESTRICTOS: if not raw_response:
- Extensión entre 500-700 palabras raise FileProcessingError("AI returned empty response")
- 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: self.logger.info(f"📝 AI response received: {len(raw_response)} characters")
{text[:20000]} self._notify(f"✅ Respuesta recibida ({len(raw_response)} caracteres)")
Puntos clave a incluir obligatoriamente: # === STEP 2: Extract clean LaTeX from AI response ===
{bullet_points}""" self._notify("🔍 Extrayendo código LaTeX...")
try: latex_content = prompt_manager.extract_latex_from_response(raw_response)
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) if not latex_content:
self.logger.info("Formatting summary with Gemini...") self.logger.warning(
format_prompt = f"""Revisa y mejora el siguiente resumen en Markdown para que sea perfectamente legible: "⚠️ No valid LaTeX found in response, treating as Markdown"
)
self._notify("⚠️ No se detectó LaTeX válido, usando modo compatible...")
# Fallback to Markdown processing
return self._fallback_to_markdown(raw_response, base_name, metadata)
{raw_summary} self.logger.info("✨ Valid LaTeX content detected")
self._notify(f"✨ LaTeX detectado: {len(latex_content)} caracteres")
Instrucciones: # === STEP 3: Compilation Loop with Self-Correction ===
- Corrige cualquier error de formato max_retries = 3
- Asegúrate de que los encabezados estén bien espaciados current_latex = latex_content
- Verifica que las viñetas usen "- " correctamente
- Mantén exactamente el contenido existente
- EVITA el uso excesivo de negritas (asteriscos), úsalas solo para conceptos clave
- Devuelve únicamente el resumen formateado sin texto adicional"""
# Use generic Gemini provider for formatting as requested for attempt in range(max_retries + 1):
from services.ai.gemini_provider import GeminiProvider # Sanitize LaTeX before saving (fix common AI errors like TikZ nodes)
formatter = GeminiProvider() current_latex = _sanitize_latex(current_latex)
try: # Save current .tex file
if formatter.is_available(): self._notify(
summary = formatter.generate_text(format_prompt) f"📄 Guardando archivo .tex (intento {attempt + 1}/{max_retries + 1})..."
)
tex_path = settings.LOCAL_DOWNLOADS_PATH / f"{base_name}.tex"
tex_path.write_text(current_latex, encoding="utf-8")
metadata["tex_path"] = str(tex_path)
# Try to compile
self._notify("⚙️ Primera pasada de compilación LaTeX...")
pdf_path = self._compile_latex(
tex_path, output_dir=settings.LOCAL_DOWNLOADS_PATH
)
if pdf_path:
self.logger.info(
f"✅ Compilation success on attempt {attempt + 1}!"
)
self._notify("✅ PDF generado exitosamente!")
metadata["pdf_path"] = str(pdf_path)
# Generate DOCX in parallel
self._notify("📄 Generando archivo DOCX en paralelo...")
docx_path = self._convert_formats_parallel(
tex_path, pdf_path, base_name
)
if docx_path:
self._notify("✅ DOCX generado exitosamente!")
metadata["docx_path"] = str(docx_path)
# Create a text summary for Notion/preview
text_summary = self._create_text_summary(current_latex)
metadata["summary_snippet"] = text_summary[:500] + "..."
# Upload to Notion in background if configured
if settings.has_notion_config:
self._notify("📤 Iniciando carga a Notion en segundo plano...")
self._upload_to_notion_background(
base_name=base_name,
summary=text_summary,
pdf_path=pdf_path,
metadata=metadata,
)
self._notify("🎉 ¡Resumen completado con éxito!")
return True, text_summary, metadata
# Compilation failed - ask AI to fix
if attempt < max_retries:
self.logger.warning(
f"⚠️ Compilation failed (Attempt {attempt + 1}/{max_retries + 1}). "
f"Requesting AI fix..."
)
self._notify(
f"⚠️ Error de compilación ({attempt + 1}/{max_retries + 1}), solicitando corrección a IA..."
)
# Get error log
log_file = settings.LOCAL_DOWNLOADS_PATH / f"{base_name}.log"
error_log = "Log file not found"
if log_file.exists():
error_log = log_file.read_text(
encoding="utf-8", errors="ignore"
)[-2000:]
# Ask AI to fix
try:
self._notify("🔧 La IA está corrigiendo el código LaTeX...")
if hasattr(self.ai_provider, "fix_latex"):
fixed_latex = self.ai_provider.fix_latex(
current_latex, error_log
)
cleaned = prompt_manager.extract_latex_from_response(
fixed_latex
)
if cleaned:
current_latex = cleaned
else:
current_latex = fixed_latex
self._notify(
"✅ Código LaTeX corregido, reintentando compilación..."
)
else:
self.logger.error(
"❌ AI provider doesn't support fix_latex()"
)
break
except Exception as e:
self.logger.error(f"❌ AI fix request failed: {e}")
break
else: else:
self.logger.warning("Gemini formatter not available, using raw summary") self.logger.error(
summary = raw_summary "❌ Max retries reached. LaTeX compilation failed."
except Exception as e: )
self.logger.warning(f"Formatting failed ({e}), using raw summary") self._notify(
summary = raw_summary "❌ No se pudo compilar el LaTeX después de varios intentos"
)
# Generate filename # If we get here, all compilation attempts failed
filename = self._generate_filename(text, summary) self._notify("⚠️ Usando modo de compatibilidad Markdown...")
return self._fallback_to_markdown(
current_latex or raw_response, base_name, metadata
)
# Create document except Exception as e:
markdown_path = self._create_markdown(summary, base_name) self.logger.error(
docx_path = self._create_docx(summary, base_name) f"❌ Critical error in document generation: {e}", exc_info=True
pdf_path = self._create_pdf(summary, base_name) )
self._notify(f"❌ Error en la generación: {str(e)[:100]}")
return False, "", metadata
metadata = { def _compile_latex(self, tex_path: Path, output_dir: Path) -> Optional[Path]:
'markdown_path': str(markdown_path), """
'docx_path': str(docx_path), Compile LaTeX to PDF using pdflatex. Runs twice for TOC.
'pdf_path': str(pdf_path),
'docx_name': Path(docx_path).name, Args:
'summary': summary, tex_path: Path to .tex file
'filename': filename output_dir: Directory for output files
Returns:
Path to generated PDF or None if failed
"""
base_name = tex_path.stem
expected_pdf = output_dir / f"{base_name}.pdf"
# Check if pdflatex is available
if not shutil.which("pdflatex"):
self.logger.error("🚫 pdflatex not found in system PATH")
return None
cmd = [
"pdflatex",
"-interaction=nonstopmode",
"-halt-on-error",
f"-output-directory={output_dir}",
str(tex_path),
]
try:
# Pass 1
self.logger.info("⚙️ Compiling LaTeX (Pass 1/2)...")
subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=120,
)
# Pass 2 (for TOC resolution)
self.logger.info("⚙️ Compiling LaTeX (Pass 2/2)...")
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=120,
)
if result.returncode == 0 and expected_pdf.exists():
self.logger.info(f"✅ PDF generated: {expected_pdf}")
self._cleanup_latex_aux(output_dir, base_name)
return expected_pdf
else:
# Read log file for error info
log_file = output_dir / f"{base_name}.log"
error_snippet = "Unknown error"
if log_file.exists():
try:
log_content = log_file.read_text(
encoding="utf-8", errors="ignore"
)
errors = [
line
for line in log_content.splitlines()
if line.startswith("!")
]
if errors:
error_snippet = errors[0][:200]
except:
pass
self.logger.error(f"❌ LaTeX compilation failed: {error_snippet}")
return None
except subprocess.TimeoutExpired:
self.logger.error("❌ LaTeX compilation timed out")
return None
except Exception as e:
self.logger.error(f"❌ Error during LaTeX execution: {e}")
return None
def _convert_tex_to_docx(self, tex_path: Path, base_name: str) -> Optional[Path]:
"""Convert .tex to .docx using Pandoc."""
if not shutil.which("pandoc"):
self.logger.warning("⚠️ pandoc not found, skipping DOCX generation")
return None
docx_path = settings.LOCAL_DOCX / f"{base_name}.docx"
cmd = ["pandoc", str(tex_path), "-o", str(docx_path)]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=60)
self.logger.info(f"✅ DOCX generated: {docx_path}")
return docx_path
except Exception as e:
self.logger.warning(f"⚠️ DOCX generation failed: {e}")
return None
def _create_text_summary(self, latex_content: str) -> str:
"""Extract a plain text summary from LaTeX content for Notion/preview."""
# Remove LaTeX commands and keep content
text = latex_content
# Remove document class and packages
text = re.sub(r"\\documentclass\[?[^\]]*\]?\{[^\}]+\}", "", text)
text = re.sub(r"\\usepackage\{[^\}]+\}", "", text)
text = re.sub(r"\\geometry\{[^\}]+\}", "", text)
text = re.sub(r"\\pagestyle\{[^\}]+\}", "", text)
text = re.sub(r"\\fancyhf\{\}", "", text)
text = re.sub(r"\\fancyhead\[?[^\]]*\]?\{[^\}]+\}", "", text)
text = re.sub(r"\\fancyfoot\[?[^\]]*\]?\{[^\}]+\}", "", text)
# Convert sections to markdown-style
text = re.sub(r"\\section\*?\{([^\}]+)\}", r"# \1", text)
text = re.sub(r"\\subsection\*?\{([^\}]+)\}", r"## \1", text)
text = re.sub(r"\\subsubsection\*?\{([^\}]+)\}", r"### \1", text)
# Remove tcolorbox environments (keep content)
text = re.sub(
r"\\begin\{(definicion|importante|ejemplo)\}\[?[^\]]*\]?",
r"\n**\1:** ",
text,
)
text = re.sub(r"\\end\{(definicion|importante|ejemplo)\}", "", text)
# Convert itemize to bullets
text = re.sub(r"\\item\s*", "- ", text)
text = re.sub(r"\\begin\{(itemize|enumerate)\}", "", text)
text = re.sub(r"\\end\{(itemize|enumerate)\}", "", text)
# Clean up math (basic)
text = re.sub(r"\$\$([^\$]+)\$\$", r"\n\n\1\n\n", text)
text = re.sub(r"\$([^\$]+)\$", r"\1", text)
# Remove remaining LaTeX commands
text = re.sub(r"\\[a-zA-Z]+(\{[^\}]*\})*", "", text)
text = re.sub(r"[{}]", "", text)
# Clean whitespace
text = re.sub(r"\n\s*\n\s*\n", "\n\n", text)
text = text.strip()
return text
def _fallback_to_markdown(
self, content: str, base_name: str, metadata: Dict[str, Any]
) -> Tuple[bool, str, Dict[str, Any]]:
"""Fallback when LaTeX generation fails."""
self.logger.warning("⚠️ Falling back to Markdown processing")
md_path = settings.LOCAL_DOWNLOADS_PATH / f"{base_name}_resumen.md"
md_path.write_text(content, encoding="utf-8")
metadata["markdown_path"] = str(md_path)
# Try to convert to PDF via pandoc
if shutil.which("pandoc"):
pdf_path = self._convert_md_to_pdf(md_path, base_name)
if pdf_path:
metadata["pdf_path"] = str(pdf_path)
docx_path = self._convert_md_to_docx(md_path, base_name)
if docx_path:
metadata["docx_path"] = str(docx_path)
metadata["summary_snippet"] = content[:500] + "..."
return True, content, metadata
def _convert_md_to_pdf(self, md_path: Path, base_name: str) -> Optional[Path]:
"""Convert Markdown to PDF using pandoc."""
pdf_path = settings.LOCAL_DOWNLOADS_PATH / f"{base_name}.pdf"
cmd = [
"pandoc",
str(md_path),
"-o",
str(pdf_path),
"--pdf-engine=pdflatex",
"-V",
"geometry:margin=2.5cm",
]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=60)
self.logger.info(f"✅ PDF from Markdown: {pdf_path}")
return pdf_path
except Exception as e:
self.logger.warning(f"⚠️ PDF from Markdown failed: {e}")
return None
def _convert_md_to_docx(self, md_path: Path, base_name: str) -> Optional[Path]:
"""Convert Markdown to DOCX using pandoc."""
docx_path = settings.LOCAL_DOCX / f"{base_name}.docx"
cmd = ["pandoc", str(md_path), "-o", str(docx_path)]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=60)
self.logger.info(f"✅ DOCX from Markdown: {docx_path}")
return docx_path
except Exception as e:
self.logger.warning(f"⚠️ DOCX from Markdown failed: {e}")
return None
def _cleanup_latex_aux(self, output_dir: Path, base_name: str):
"""Clean up auxiliary LaTeX files."""
extensions = [".aux", ".log", ".out", ".toc"]
for ext in extensions:
aux_file = output_dir / f"{base_name}{ext}"
if aux_file.exists():
try:
aux_file.unlink()
except:
pass
def _upload_to_notion(
self,
base_name: str,
summary: str,
pdf_path: Optional[Path],
metadata: Dict[str, Any],
):
"""Upload summary to Notion if configured."""
try:
from services.notion_service import notion_service
title = base_name.replace("_", " ").title()
notion_metadata = {
"file_type": "Audio",
"pdf_path": pdf_path or Path(""),
"add_status": False,
"use_as_page": False,
} }
return True, summary, metadata page_id = notion_service.create_page_with_summary(
title=title, summary=summary, metadata=notion_metadata
)
except Exception as e: if page_id:
self.logger.error(f"Document generation process failed: {e}") metadata["notion_uploaded"] = True
return False, "", {} metadata["notion_page_id"] = page_id
self.logger.info(f"✅ Uploaded to Notion: {title}")
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 with Markdown parsing (Legacy method ported)"""
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)
# Parse and render Markdown content line by line
lines = summary.splitlines()
current_paragraph = []
for line in lines:
line = line.strip()
if not line:
if current_paragraph:
p = doc.add_paragraph(' '.join(current_paragraph))
p.alignment = 3 # JUSTIFY alignment (WD_ALIGN_PARAGRAPH.JUSTIFY=3)
current_paragraph = []
continue
if line.startswith('#'):
if current_paragraph:
p = doc.add_paragraph(' '.join(current_paragraph))
p.alignment = 3
current_paragraph = []
# Process heading
level = len(line) - len(line.lstrip('#'))
heading_text = line.lstrip('#').strip()
if level <= 6:
doc.add_heading(heading_text, level=level)
else:
current_paragraph.append(heading_text)
elif line.startswith('-') or line.startswith('*') or line.startswith(''):
if current_paragraph:
p = doc.add_paragraph(' '.join(current_paragraph))
p.alignment = 3
current_paragraph = []
bullet_text = line.lstrip('-*• ').strip()
p = doc.add_paragraph(bullet_text, style='List Bullet')
# Remove bold markers from bullets if present
if '**' in bullet_text:
# Basic cleanup for bullets
pass
else: else:
# Clean up excessive bold markers in body text if user requested self.logger.warning(f"⚠️ Notion upload failed: {title}")
clean_line = line.replace('**', '') # Removing asterisks as per user complaint "se abusa de los asteriscos"
current_paragraph.append(clean_line)
if current_paragraph:
p = doc.add_paragraph(' '.join(current_paragraph))
p.alignment = 3
doc.add_page_break() except Exception as e:
doc.add_paragraph(f"*Generado por CBCFacil*") self.logger.warning(f"❌ Notion upload error: {e}")
doc.save(output_path)
return output_path
def _create_pdf(self, summary: str, base_name: str) -> Path:
"""Create PDF document with Markdown parsing (Legacy method ported)"""
try:
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
import textwrap
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
margin = 72
y_position = height - margin
def new_page():
nonlocal y_position
c.showPage()
c.setFont('Helvetica', 11)
y_position = height - margin
c.setFont('Helvetica', 11)
# Title
c.setFont('Helvetica-Bold', 16)
c.drawString(margin, y_position, base_name.replace('_', ' ').title()[:100])
y_position -= 28
c.setFont('Helvetica', 11)
summary_clean = summary.replace('**', '') # Remove asterisks globally for cleaner PDF
for raw_line in summary_clean.splitlines():
line = raw_line.rstrip()
if not line.strip():
y_position -= 14
if y_position < margin:
new_page()
continue
stripped = line.lstrip()
if stripped.startswith('#'):
level = len(stripped) - len(stripped.lstrip('#'))
heading_text = stripped.lstrip('#').strip()
if heading_text:
font_size = 16 if level == 1 else 14 if level == 2 else 12
c.setFont('Helvetica-Bold', font_size)
c.drawString(margin, y_position, heading_text[:90])
y_position -= font_size + 6
if y_position < margin:
new_page()
c.setFont('Helvetica', 11)
continue
if stripped.startswith(('-', '*', '')):
bullet_text = stripped.lstrip('-*•').strip()
wrapped_lines = textwrap.wrap(bullet_text, width=80) or ['']
for idx, wrapped in enumerate(wrapped_lines):
prefix = '' if idx == 0 else ' '
c.drawString(margin, y_position, f"{prefix}{wrapped}")
y_position -= 14
if y_position < margin:
new_page()
continue
# Body text - Justified approximation (ReportLab native justification requires Paragraph styles, defaulting to wrap)
wrapped_lines = textwrap.wrap(stripped, width=90) or ['']
for wrapped in wrapped_lines:
c.drawString(margin, y_position, wrapped)
y_position -= 14
if y_position < margin:
new_page()
c.save()
return output_path

View File

@@ -0,0 +1,447 @@
\documentclass[11pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[spanish,provide=*]{babel}
\usepackage{amsmath,amssymb}
\usepackage{geometry}
\usepackage{graphicx}
\usepackage{tikz}
\usetikzlibrary{arrows.meta,positioning,shapes.geometric,calc}
\usepackage{booktabs}
\usepackage{enumitem}
\usepackage{fancyhdr}
\usepackage{titlesec}
\usepackage{tcolorbox}
\usepackage{array}
\usepackage{multirow}
\geometry{margin=2.5cm}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{Economía - CBC}
\fancyhead[R]{Clase: Revolución Rusa y Crisis del 30}
\fancyfoot[C]{\thepage}
% Cajas para destacar contenido
\newtcolorbox{definicion}[1][]{
colback=blue!5!white,
colframe=blue!75!black,
fonttitle=\bfseries,
title=#1
}
\newtcolorbox{importante}[1][]{
colback=red!5!white,
colframe=red!75!black,
fonttitle=\bfseries,
title=#1
}
\newtcolorbox{ejemplo}[1][]{
colback=green!5!white,
colframe=green!50!black,
fonttitle=\bfseries,
title=#1
}
\title{\textbf{Revolución Rusa y Crisis del 30}\\
\large Ciclos Económicos, Socialismo y la Gran Depresión}
\author{CBC - UBA}
\date{\today}
\begin{document}
\maketitle
\tableofcontents
\newpage
\section{Introducción}
La presente clase aborda dos de los procesos económicos más transformadores del siglo XX: la \textbf{Revolución Rusa} y la \textbf{Gran Depresión de 1929}. Ambos eventos representan puntos de inflexión en la historia económica mundial y dan lugar a nuevas formas de organización económica, así como a teorías económicas que buscan explicar y solucionar las crisis capitalistas.
El contexto de la Revolución Rusa se sitúa en un imperio zarista en crisis, donde las ideas marxistas encuentran terreno fértil tras años de marginalización del proletariado industrial y rural. Por otro lado, la Crisis del 30 representa el colapso del modelo económico liberal y el surgimiento de nuevas teorías intervencionistas.
\section{Contexto Histórico: El Siglo XIX y las Ideas Revolucionarias}
\subsection{El surgimiento del proletariado y las ideas marxistas}
Durante la década de 1830-1840, se observó un fenómeno social particular: la \textbf{invención del proletariado} por parte de la burguesía media. Este término se refiere a la clase trabajadora industrial que surgió con la Revolución Industrial.
\begin{definicion}[Proletariado]
El proletariado es la clase social que carece de medios de producción y debe vender su fuerza de trabajo para subsistir. Surge con la industrialización y la concentración de la propiedad en manos de la burguesía.
\end{definicion}
Según Kovalevsky, \textit{"el comunismo se asoma sobre Europa"} en este período. Los trabajadores comenzaron a enarbolar la \textbf{bandera roja} en lugar de las banderas nacionales francesas, simbolizando la identificación con la ideología marxista.
\subsection{El crecimiento económico y el paréntesis revolucionario}
El crecimiento económico impulsado por la \textbf{Revolución de los Transportes} (ferrocarriles, barcos a vapor) puso temporalmente en reposo las ideas revolucionarias. La expansión económica generó empleos y mejoró las condiciones de vida, postergando las tensiones sociales.
\begin{importante}[Idea clave]
El crecimiento económico funciona como un \textit{paréntesis} para las ideas revolucionarias. Es necesario esperar la Gran Depresión para que estas ideas resurjan con fuerza.
\end{importante}
\subsection{La disyuntiva del marxismo: socialdemocracia vs. revolución}
Con el desarrollo de las ideas marxistas, surgió una disyuntiva fundamental:
\begin{itemize}
\item \textbf{Socialdemocracia}: Acceder al poder político a través del sistema democrático y utilizar el Estado para generar condiciones de igualdad y redistribución.
\item \textbf{Revolución proletaria}: Toma del poder por la fuerza de la clase trabajadora para establecer la dictadura del proletariado.
\end{itemize}
\section{Estados de Bienestar vs. Estados Intervencionistas}
Es fundamental distinguir entre dos tipos de intervención estatal que surgieron en este período:
\begin{table}[h]
\centering
\begin{tabular}{@{}p{0.45\textwidth}@{}p{0.45\textwidth}@{}}
\toprule
\textbf{Estado Intervencionista} & \textbf{Estado de Bienestar} \\
\midrule
Protege el producto nacional ante la competencia extranjera & Protege a los trabajadores y busca el bienestar de la población \\
Implementa aranceles y barreras comerciales & Garantiza derechos laborales y seguridad social \\
Fomenta la industria nacional & Provee servicios de salud, educación y vivienda \\
\bottomrule
\end{tabular}
\caption{Comparación entre Estado Intervencionista y Estado de Bienestar}
\end{table}
\subsection{Conquistas laborales del período}
Las luchas obreras de este período lograron conquistas fundamentales:
\begin{itemize}
\item \textbf{Jornada de 8 horas}: 8 horas de trabajo, 8 horas de descanso, 8 horas de libre disposición
\item \textbf{Protección infantil}: Limitación del trabajo infantil
\item \textbf{Protección materna}: Derechos para mujeres embarazadas
\item \textbf{Condiciones de trabajo}: Mejoras en seguridad y salubridad
\end{itemize}
\section{La Revolución Rusa}
\subsection{Antecedentes: la Rusia zarista en crisis}
La Rusia zarista presentaba características particulares que la diferenciaban de otras potencias europeas:
\begin{itemize}
\item \textbf{Economía predominantemente agraria}: 80\% de la economía era agrícola a fines del siglo XIX
\item \textbf{Industrialización tardía e incompleta}: A diferencia de Estados Unidos y Alemania, Rusia no se había industrializado significativamente
\item \textbf{Crisis agraria}: Durante la Gran Depresión, hubo hambrunas generalizadas
\item \textbf{Participación en la Primera Guerra Mundial}: Rusia no tuvo un buen desempeño bélico
\end{itemize}
\begin{importante}[Contradicción fundamental]
La teoría marxista preveía una revolución proletaria industrial, pero Rusia era un país 80\% agrícola. Esta contradicción es central para entender el desarrollo posterior de la revolución.
\end{importante}
\subsection{Las leyes de Marx y la situación rusa}
Las tres leyes de Marx parecían cumplirse en Rusia:
\begin{enumerate}
\item \textbf{Caída de precios} $\rightarrow$ \textbf{Caída de la tasa de beneficio}
\item \textbf{Caída de la tasa de beneficio} $\rightarrow$ \textbf{Caída de la producción}
\item \textbf{Caída de la producción} $\rightarrow$ \textbf{Aumento del desempleo}
\item \textbf{Aumento del desempleo} $\rightarrow$ \textbf{Descontento social} $\rightarrow$ \textbf{Revolución}
\end{enumerate}
\subsection{La válvula de escape: la migración}
En Europa, la migración masiva a América funcionó como una válvula de escape que redujo las tensiones sociales. Millones de personas cruzaron el océano para trabajar, evitando que las tensiones políticas alcanzaran niveles críticos.
\begin{ejemplo}[Migración italiana]
El campesino italiano viajaba 15.000 kilómetros para cosechar meta en América y volver a Italia con ahorros. Esta migración evitó concentraciones demográficas que hubieran alimentado el conflicto social.
\end{ejemplo}
\subsection{El proceso revolucionario}
\subsubsection{Caída del Zar Nicolás II}
El zar Nicolás II perdió legitimidad ante la sociedad. La Primera Guerra Mundial exacerbó los problemas económicos y generó hambruna tanto en las ciudades como en el frente de batalla.
\subsubsection{Revolución de febrero de 1917}
Liderada por \textbf{Vladimir Lenin}, esta revolución inicial derrocó al zar. Sin embargo, existe una contradicción fundamental: la revolución fue apoyada principalmente por el campo, no por los trabajadores industriales.
\begin{definicion}[Válvula de escape histórica]
La migración masiva a América funcionó como una válvula de escape que redujo las tensiones sociales en Europa. Al permitir que millones de personas encontraran trabajo en el Nuevo Mundo, se evitó que las tensiones políticas alcanzaran niveles críticos.
\end{definicion}
\section{Tres Etapas de la Revolución Rusa}
La Revolución Rusa se desarrolló en tres etapas bien diferenciadas, cada una con políticas económicas específicas.
\subsection{Primera Etapa: Comunismo de Guerra (1918-1921)}
El Comunismo de Guerra implementó medidas radicales de transformación económica:
\begin{enumerate}
\item \textbf{Expropiación de tierras}: Las tierras de la nobleza y grandes terratenientes fueron expropiadas y entregadas \textit{en propiedad privada} a los campesinos que apoyaron la revolución.
\begin{importante}[Primera contradicción]
La revolución socialista creó propietarios privados en el campo. Esto contradice el principio marxista de abolición de la propiedad privada.
\end{importante}
\item \textbf{Control de las empresas}: Los trabajadores tomaron el control de las empresas, expropiando a los propietarios anteriores.
\item \textbf{Creación de los Soviets}: Consejos de delegados de las diferentes fábricas que decidían sobre producción, destino y productividad.
\item \textbf{Consejo económico supremo}: Organismo central para dirigir el comercio interno, externo y las relaciones comerciales.
\item \textbf{Nacionalización de la banca}: Los bancos más importantes fueron nacionalizados.
\item \textbf{Desconocimiento de la deuda externa}: La Unión Soviética desconoció la deuda externa contraída por el zar.
\end{enumerate}
\subsubsection{Resultados del Comunismo de Guerra}
\begin{itemize}
\item \textbf{Caída de la productividad agrícola}: Al dividir las grandes haciendas en parcelas pequeñas, la producción disminuyó
\item \textbf{Hambruna persistente}: Los problemas para alimentar a las ciudades continuaron
\item \textbf{Represión estatal}: El Estado comenzó a sancionar, reprimir y requisar producción
\item \textbf{Estancamiento industrial}: Sin capital para invertir en maquinaria agrícola, la industria pesada no se desarrolló
\end{itemize}
\subsection{Segunda Etapa: Nueva Política Económica - NEP (1921-1928)}
Ante el fracaso del Comunismo de Guerra, se implementó la NEP bajo el liderazgo de Lenin:
\begin{definicion}[NEP - Nueva Política Económica]
La NEP consistió en otorgar libertad de mercado al sector agrícola dentro de una revolución marxista. Los campesinos podían vender su producción en el mercado libre, determinando precios y destinos.
\end{definicion}
\textbf{Objetivo de la NEP:}
\begin{itemize}
\item Reactivar la producción agrícola
\item Generar excedentes que volcar a la industrialización
\item Crear un círculo virtuoso: más agricultura $\rightarrow$ más demanda de maquinaria $\rightarrow$ más industria $\rightarrow$ más empleo $\rightarrow$ más consumo
\end{itemize}
\subsubsection{La contradicción de la NEP}
\begin{importante}[Renuncia a la industrialización acelerada]
La NEP implicaba renunciar a la industrialización acelerada porque requería esperar el \textit{"derrame"} desde el campo. La transición del feudalismo al capitalismo había demorado dos siglos; la URSS no podía esperar tanto.
\end{importante}
\subsubsection{La grieta en el partido bolchevique}
Se abrió una división interna:
\begin{table}[h]
\centering
\begin{tabular}{@{}p{0.45\textwidth}@{}p{0.45\textwidth}@{}}
\toprule
\textbf{Defensores de la NEP} & \textbf{Partidarios de industrialización acelerada} \\
\midrule
Esperar el desarrollo del campo & Industrialización forzada desde el Estado \\
Derrame espontáneo del capital & Planificación centralizada \\
Stalin (luego de cambiar de posición) & Trotsky \\
\bottomrule
\end{tabular}
\caption{Divisiones en el partido bolchevique}
\end{table}
\subsection{Tercera Etapa: Colectivización e Industrialización Forzada (1928-1941)}
Bajo el liderazgo de \textbf{Iósif Stalin}, se implementó la colectivización forzosa:
\begin{itemize}
\item \textbf{Colectivización de la tierra}: Las parcelas privadas fueron reunidas en granjas estatales (\textit{koljoses} y \textit{sovjoses})
\item \textbf{Eliminación de la propiedad privada}: El campesino dueño de una parcela la perdió en favor del Estado
\item \textbf{Industrialización pesada}: Todos los recursos se volcaron a la industria pesada
\end{itemize}
\begin{importante}[Costo humano]
La colectivización causó millones de muertes. Sumado a las víctimas de la Primera Guerra Mundial, la Guerra Civil y la Segunda Guerra Mundial, el costo humano de la transformación soviética fue enorme.
\end{importante}
\subsubsection{Resultados de la colectivización}
\begin{itemize}
\item 75\% del comercio interno en manos del Estado
\item Banca 100\% estatal
\item Solo 3\% del sector agrícola permaneció en manos privadas
\item Mientras Occidente se hundía en la Gran Depresión, la URSS entraba en la senda de la industrialización pesada
\end{itemize}
\section{La Gran Depresión de 1929}
\subsection{Contexto: los gloriosos años 20 en Estados Unidos}
Estados Unidos emergió de la Primera Guerra Mundial como:
\begin{itemize}
\item Uno de los mayores beneficiados del conflicto
\item Mayor exportador de manufacturas
\item Nueva potencia financiera (desplazó a Inglaterra)
\end{itemize}
La década de 1920 se caracterizó por:
\begin{itemize}
\item Crecimiento económico aparentemente infinito
\item Aumento del salario real del trabajador promedio
\item Expansión del consumo a crédito
\item Especulación bursátil
\end{itemize}
\subsection{Causales de la crisis (no solo el crack)}
Es fundamental entender que el \textbf{Jueves Negro} (caída de la bolsa) no fue la causa, sino el detonante de una serie de procesos:
\subsubsection{Políticas que inflaron la burbuja}
\begin{enumerate}
\item \textbf{Vuelta al proteccionismo}: Estados Unidos volvió a su política tradicional de proteger su mercado interno con altos aranceles, cerrando mercados a las exportaciones europeas.
\item \textbf{Aumento de tasas de interés}: La Reserva Federal aumentó las tasas, atrayendo capitales de todo el mundo hacia Estados Unidos.
\begin{ejemplo}[Fuga de capitales]
Los capitales que estaban invertidos en Alemania y Latinoamérica se retiraron masivamente para buscar mayores retornos en Estados Unidos. Esto provocó crisis económicas en la periferia.
\end{ejemplo}
\item \textbf{Especulación bursátil}: Los capitales no se volcaron a la producción real (fábricas, empleo), sino a la especulación con acciones.
\item \textbf{Economía frágil en el crédito}: Tanto la economía norteamericana como la mundial dependían excesivamente del crédito.
\end{enumerate}
\subsection{El ciclo de la crisis}
\begin{center}
\begin{tikzpicture}[
node distance=1.5cm,
auto,
block/.style={rectangle, draw, fill=blue!10, text width=6cm, text centered, rounded corners, minimum height=1cm},
arrow/.style={-Stealth, thick}
]
\node [block] (tasa) {Aumento de tasas de interés en EE.UU.};
\node [block, below=of tasa] (fuga) {Fuga de capitales de periferia y Europa};
\node [block, below=of fuga] (crisis) {Crisis económica en Europa y Latinoamérica};
\node [block, below=of crisis] (reduccion) {Reducción de importaciones de manufacturas};
\node [block, below=of reduccion] (sobreproduccion) {Sobreproducción en EE.UU.};
\node [block, below=of sobreproduccion] (desempleo) {Caída de producción $\rightarrow$ Desempleo};
\node [block, below=of desempleo, fill=red!10] (especulacion) {Especulación bursátil (capitales sin producción real)};
\node [block, below=of especulacion, fill=red!20] (crash) {CRACK DEL JUEVES NEGRO};
\draw [arrow] (tasa) -- (fuga);
\draw [arrow] (fuga) -- (crisis);
\draw [arrow] (crisis) -- (reduccion);
\draw [arrow] (reduccion) -- (sobreproduccion);
\draw [arrow] (sobreproduccion) -- (desempleo);
\draw [arrow] (desempleo) -- (especulacion);
\draw [arrow] (especulacion) -- (crash);
\end{tikzpicture}
\end{center}
\subsection{Consecuencias del crack}
\subsubsection{Pánico bancario}
La desconfianza se contagió del mercado bursátil a los bancos:
\begin{enumerate}
\item Los tenedores de acciones vendieron masivamente
\item La desconfianza se extendió a los depositantes bancarios
\item Retiros masivos de ahorros (corridas bancarias)
\item Los bancos no tenían fondos para hacer frente a los retiros
\item Más de la mitad de los bancos de EE.UU. quebraron
\end{enumerate}
\subsubsection{Efectos reales de la Gran Depresión}
\begin{itemize}
\item \textbf{Caída de la producción}: Las empresas quebraron por falta de liquidez
\item \textbf{Desempleo masivo}: El desempleo llegó al 25\%
\item \textbf{Caída del consumo}: De la prosperidad de los años 20 a filas por un plato de comida
\item \textbf{Pérdida de ahorros}: Los trabajadores perdieron sus ahorros depositados en bancos quebrados
\end{itemize}
\begin{importante}[Distinción crucial]
Es fundamental distinguir entre las \textbf{explicaciones} de la crisis (teorías económicas que surgieron para explicarla) y las \textbf{causales} de la crisis (los procesos concretos que la provocaron).
\end{importante}
\section{Comparación: Revolución Rusa vs. Gran Depresión}
\begin{table}[h]
\centering
\small
\begin{tabular}{@{}p{0.45\textwidth}@{}p{0.45\textwidth}@{}}
\toprule
\textbf{Revolución Rusa} & \textbf{Gran Depresión} \\
\midrule
Crisis en economía agrícola atrasada & Crisis en economía industrial avanzada \\
Respuesta: revolución socialista & Respuesta: Nuevas teorías económicas intervencionistas \\
Estado toma control de la economía & Estado interviene para corregir mercado \\
Planificación centralizada & Mantención de mercado con regulación \\
Colectivización forzada & Keynesianismo y New Deal \\
\bottomrule
\end{tabular}
\caption{Comparación de las dos grandes crisis del siglo XX}
\end{table}
\section{Glosario de Términos Técnicos}
\begin{description}[style=multiline, leftmargin=3cm, font=\bfseries]
\item[Proletariado] Clase trabajadora industrial que carece de medios de producción y debe vender su fuerza de trabajo.
\item[Socialdemocracia] Corriente política que busca acceder al poder por vía democrática para implementar reformas sociales y económicas graduales.
\item[Estado de Bienestar] Forma de Estado que garantiza servicios sociales y protección a los ciudadanos (salud, educación, seguridad social).
\item[Estado Intervencionista] Estado que interviene activamente en la economía para proteger la producción nacional y regular el mercado.
\item[Comunismo de Guerra] Primera etapa de la Revolución Rusa caracterizada por la expropiación de tierras y empresas, y nacionalización de la banca.
\item[NEP] Nueva Política Económica implementada por Lenin que otorgó libertad de mercado al sector agrícola para reactivar la economía.
\item[Colectivización] Proceso de reunir las tierras privadas en granjas estatales, eliminando la propiedad privada de la tierra.
\item[Koljoses] Granjas colectivas en la Unión Soviética donde los campesinos trabajaban la tierra en cooperativa.
\item[Sovjoses] Granjas estatales en la Unión Soviética administradas directamente por el Estado.
\item[Soviets] Consejos de delegados obreros y campesinos que surgieron durante la Revolución Rusa.
\item[Socialismo marxista] Sistema económico basado en la propiedad social de los medios de producción y la abolición de la propiedad privada.
\item[Keynesianismo] Teoría económica desarrollada por John Maynard Keynes que aboga por la intervención del Estado para regular los ciclos económicos.
\item[New Deal] Conjunto de políticas implementadas por Franklin D. Roosevelt para combatir la Gran Depresión en Estados Unidos.
\item[Taylorismo] Sistema de organización científica del trabajo desarrollado por Frederick Taylor que maximiza la eficiencia productiva.
\item[Fordismo] Sistema de producción en cadena desarrollado por Henry Ford que combina producción masiva con salarios altos para consumo masivo.
\end{description}
\section{Conclusiones}
\subsection{Lecciones de la Revolución Rusa}
\begin{enumerate}
\item La revolución marxista no se dio en un país industrializado, sino en uno agrario, contradiciendo las predicciones de Marx.
\item Las tres etapas (Comunismo de Guerra, NEP, Colectivización) muestran las tensiones entre teoría y práctica.
\item El costo humano de la industrialización forzada fue enorme.
\item La burocratización del Estado capturó la revolución, creando una nueva élite.
\end{enumerate}
\subsection{Lecciones de la Gran Depresión}
\begin{enumerate}
\item Las políticas monetarias y comerciales de una potencia tienen efectos globales.
\item La especulación financiera desacoplada de la producción real genera burbujas insostenibles.
\item La economía basada en el crédito es vulnerable a pánicos y corridas.
\item El colapso del sistema financiero se transmite rápidamente a la economía real.
\end{enumerate}
\subsection{Impacto en la teoría económica}
Ambos eventos llevaron al desarrollo de nuevas teorías económicas:
\begin{itemize}
\item La planificación centralizada como alternativa al mercado
\item El keynesianismo y la macroeconomía moderna
\item El Estado de bienestar como estabilizador social
\item La regulación financiera como prevención de crisis
\end{itemize}
\end{document}

1089
latex/imperio_romano.tex Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
# Pipeline de Generación de Resúmenes Matemáticos (LaTeX -> PDF)
Este documento contiene un script genérico en Python diseñado para integrarse en pipelines de automatización (GitHub Actions, Jenkins, GitLab CI). El script toma un archivo de texto plano, genera un resumen académico con fórmulas matemáticas usando LLMs (MiniMax, GLM, Gemini) y lo compila a PDF preservando la notación LaTeX.
## 1. Requisitos del Sistema
El entorno donde se ejecute este script debe tener instalado:
- **Python 3.8+**
- **Pandoc** (para conversión de documentos)
- **PDFLaTeX** (generalmente parte de TexLive, para renderizar fórmulas)
### Instalación en Debian/Ubuntu (Docker o CI)
```bash
apt-get update && apt-get install -y pandoc texlive-latex-base texlive-fonts-recommended python3-pip
pip install requests
```
## 2. Script Genérico (`math_summary.py`)
Guarda el siguiente código como `math_summary.py`. Este script es agnóstico al proveedor y se configura mediante argumentos o variables de entorno.
```python
#!/usr/bin/env python3
import os
import sys
import argparse
import subprocess
import requests
import json
# Configuración de Modelos
PROVIDERS = {
"minimax": {
"url": "https://api.minimax.io/anthropic/v1/messages",
"model": "MiniMax-M2",
"header_key": "x-api-key",
"version_header": {"anthropic-version": "2023-06-01"},
"env_var": "MINIMAX_API_KEY"
},
"glm": {
"url": "https://api.z.ai/api/anthropic/v1/messages",
"model": "glm-4.7",
"header_key": "x-api-key",
"version_header": {"anthropic-version": "2023-06-01"},
"env_var": "GLM_API_KEY"
}
}
PROMPT_SYSTEM = """
Eres un asistente académico experto en matemáticas y economía.
Tu tarea es resumir el texto proporcionado manteniendo el rigor científico.
REGLAS DE FORMATO (CRÍTICO):
1. La salida debe ser Markdown válido.
2. TODAS las fórmulas matemáticas deben estar en formato LaTeX.
3. Usa bloques $$ ... $$ para ecuaciones centradas importantes.
4. Usa $ ... $ para ecuaciones en línea.
5. NO uses bloques de código (```latex) para las fórmulas, úsalas directamente en el texto para que Pandoc las renderice.
6. Incluye una sección de 'Conceptos Matemáticos' con las fórmulas desglosadas.
"""
def get_api_key(provider):
env_var = PROVIDERS[provider]["env_var"]
key = os.getenv(env_var)
if not key:
print(f"Error: La variable de entorno {env_var} no está definida.")
sys.exit(1)
return key
def call_llm(provider, text, api_key):
print(f"--- Contactando API: {provider.upper()} ---")
config = PROVIDERS[provider]
headers = {
"Content-Type": "application/json",
config["header_key"]: api_key,
}
if "version_header" in config:
headers.update(config["version_header"])
payload = {
"model": config["model"],
"max_tokens": 4096,
"messages": [
{"role": "user", "content": f"{PROMPT_SYSTEM}\n\nTEXTO A RESUMIR:\n{text}"}
]
}
try:
resp = requests.post(config["url"], json=payload, headers=headers, timeout=120)
resp.raise_for_status()
data = resp.json()
# Manejo específico para MiniMax que puede devolver bloques de "thinking"
content = ""
for part in data.get("content", []):
if part.get("type") == "text":
content += part.get("text", "")
# Fallback si no hay tipo explícito (GLM estándar)
if not content and data.get("content"):
if isinstance(data["content"], list):
content = data["content"][0].get("text", "")
return content
except Exception as e:
print(f"Error llamando a {provider}: {e}")
return None
def convert_to_pdf(markdown_content, output_file):
base_name = os.path.splitext(output_file)[0]
md_file = f"{base_name}.md"
with open(md_file, "w", encoding="utf-8") as f:
f.write(markdown_content)
print(f"--- Generando PDF: {output_file} ---")
cmd = [
"pandoc", md_file,
"-o", output_file,
"--pdf-engine=pdflatex",
"-V", "geometry:margin=2.5cm",
"-V", "fontsize=12pt",
"--highlight-style=tango"
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print("Éxito: PDF generado correctamente.")
return True
else:
print("Error en Pandoc:")
print(result.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Generador de Resúmenes Matemáticos PDF")
parser.add_argument("input_file", help="Ruta al archivo de texto (.txt) fuente")
parser.add_argument("--provider", choices=["minimax", "glm"], default="glm", help="Proveedor de IA a usar")
parser.add_argument("--output", default="resumen_output.pdf", help="Nombre del archivo PDF de salida")
args = parser.parse_args()
if not os.path.exists(args.input_file):
print(f"Error: No se encuentra el archivo {args.input_file}")
sys.exit(1)
with open(args.input_file, "r", encoding="utf-8") as f:
text_content = f.read()
api_key = get_api_key(args.provider)
summary_md = call_llm(args.provider, text_content, api_key)
if summary_md:
convert_to_pdf(summary_md, args.output)
else:
print("Fallo en la generación del resumen.")
sys.exit(1)
if __name__ == "__main__":
main()
```
## 3. Ejemplo de Uso en Pipeline
### Ejecución Local
```bash
export GLM_API_KEY="tu_api_key_aqui"
python3 math_summary.py entrada.txt --provider glm --output reporte_final.pdf
```
### GitHub Actions (Ejemplo .yaml)
Este paso automatizaría la creación del PDF cada vez que se sube un .txt a la carpeta `docs/`.
```yaml
name: Generar PDF Matemático
on:
push:
paths:
- 'docs/*.txt'
jobs:
build-pdf:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Instalar dependencias
run: |
sudo apt-get update
sudo apt-get install -y pandoc texlive-latex-base texlive-fonts-recommended
pip install requests
- name: Generar Resumen
env:
GLM_API_KEY: ${{ secrets.GLM_API_KEY }}
run: |
python3 math_summary.py docs/archivo.txt --provider glm --output docs/resumen.pdf
- name: Subir Artefacto
uses: actions/upload-artifact@v3
with:
name: PDF-Resumen
path: docs/resumen.pdf
```

221
latex/resumen.md Normal file
View File

@@ -0,0 +1,221 @@
# Prompt para Generar Resúmenes Académicos en LaTeX
## Instrucciones de Uso
1. Transcribir la clase (audio a texto) usando Whisper o similar
2. Tener el material bibliográfico en formato digital (PDF escaneado con OCR o texto)
3. Copiar el prompt de abajo y completar los campos entre `[corchetes]`
---
## Prompt Template
```
Sos un asistente académico experto en [MATERIA]. Tu tarea es crear un resumen extenso y detallado en LaTeX basado en la transcripción de clase y el material bibliográfico que te proporciono.
## Material de entrada
### Transcripción de clase:
[PEGAR TRANSCRIPCIÓN AQUÍ]
### Material bibliográfico de apoyo:
[PEGAR TEXTO DEL LIBRO/APUNTE O INDICAR QUE LO SUBISTE COMO ARCHIVO]
## Requisitos del resumen
### Extensión y profundidad:
- Mínimo 10 páginas
- Cubrir TODOS los temas mencionados en clase
- Expandir cada concepto con definiciones formales del material bibliográfico
- No resumir demasiado: preferir explicaciones completas
### Estructura obligatoria:
1. Portada con título, materia, fecha y tema
2. Índice (table of contents)
3. Introducción contextualizando el tema
4. Desarrollo organizado en secciones y subsecciones
5. Tablas comparativas cuando haya clasificaciones o tipos
6. Diagramas con TikZ cuando haya procesos, flujos o relaciones
7. Cajas destacadas para definiciones, ejemplos y conceptos importantes
8. Fórmulas matemáticas cuando corresponda
9. Glosario de términos técnicos al final
10. Referencias al material bibliográfico
### Formato LaTeX requerido:
```latex
\documentclass[11pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[spanish,provide=*]{babel}
\usepackage{amsmath,amssymb}
\usepackage{geometry}
\usepackage{graphicx}
\usepackage{tikz}
\usetikzlibrary{arrows.meta,positioning,shapes.geometric,calc}
\usepackage{booktabs}
\usepackage{enumitem}
\usepackage{fancyhdr}
\usepackage{titlesec}
\usepackage{tcolorbox}
\usepackage{array}
\usepackage{multirow}
\geometry{margin=2.5cm}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{[MATERIA] - CBC}
\fancyhead[R]{Clase [N]}
\fancyfoot[C]{\thepage}
% Cajas para destacar contenido
\newtcolorbox{definicion}[1][]{
colback=blue!5!white,
colframe=blue!75!black,
fonttitle=\bfseries,
title=#1
}
\newtcolorbox{importante}[1][]{
colback=red!5!white,
colframe=red!75!black,
fonttitle=\bfseries,
title=#1
}
\newtcolorbox{ejemplo}[1][]{
colback=green!5!white,
colframe=green!50!black,
fonttitle=\bfseries,
title=#1
}
```
### Estilo de contenido:
- Usar \textbf{} para términos clave en su primera aparición
- Usar \textit{} para énfasis y palabras en otros idiomas
- Incluir ejemplos concretos mencionados en clase
- Relacionar teoría con casos prácticos
- Mantener el tono académico pero accesible
- Si el profesor hizo énfasis en algo ("esto es importante", "esto entra en el parcial"), destacarlo en caja roja
### Elementos visuales:
- Tablas con booktabs para comparaciones (usar \toprule, \midrule, \bottomrule)
- Diagramas TikZ para flujos, ciclos o relaciones entre conceptos
- Listas itemize/enumerate para secuencias o características
- Fórmulas centradas con equation o align para expresiones matemáticas
## Ejemplo de calidad esperada
Para cada concepto principal:
1. Definición formal (del libro)
2. Explicación en palabras simples (como lo explicó el profesor)
3. Ejemplo concreto
4. Relación con otros conceptos
5. Por qué es importante / para qué sirve
## Output
Generá el archivo .tex completo, listo para compilar con pdflatex (dos pasadas para el índice).
```
---
## Comandos para compilar
```bash
# Compilar (dos veces para índice)
pdflatex resumen_clase_X.tex
pdflatex resumen_clase_X.tex
# Abrir PDF
xdg-open resumen_clase_X.pdf # Linux
open resumen_clase_X.pdf # macOS
```
---
## Pipeline completo
### 1. Transcripción de audio (con Whisper)
```bash
# Instalar whisper
pip install openai-whisper
# Transcribir audio de clase
whisper "clase_X.mp3" --language Spanish --output_format txt
```
### 2. OCR de PDFs escaneados (con marker-pdf)
```bash
# Crear entorno virtual
python -m venv .venv
source .venv/bin/activate
# Instalar marker
pip install marker-pdf
# Procesar PDF (usa GPU si está disponible)
marker_single "libro_capitulo_X.pdf" --output_dir output/
```
### 3. Generar resumen
Usar el prompt de arriba con:
- Claude (Anthropic)
- GPT-4 (OpenAI)
- Gemini (Google)
### 4. Compilar LaTeX
```bash
pdflatex resumen.tex && pdflatex resumen.tex
```
---
## Tips para mejores resultados
1. **Transcripción completa**: No cortar la transcripción, la IA necesita todo el contexto
2. **Material bibliográfico**: Incluir los capítulos específicos, no todo el libro
3. **Ser específico**: Indicar la materia, el nivel (CBC, carrera, posgrado) y el enfoque del profesor
4. **Iterar**: Si el primer resultado es corto, pedir "expandí la sección X con más detalle"
5. **Diagramas**: Si hay un diagrama importante, describirlo y pedir que lo haga en TikZ
6. **Revisar**: La IA puede cometer errores conceptuales, siempre verificar con el material
---
## Materias donde funciona bien
- Economía (micro/macro)
- Física
- Química
- Matemática (álgebra, análisis)
- Biología
- Sociología
- Historia
- Derecho (con adaptaciones)
- Cualquier materia con contenido teórico estructurado
---
## Ejemplo de uso rápido
```
Sos un asistente académico experto en Física. Creá un resumen extenso en LaTeX sobre "Cinemática" basado en esta transcripción de clase del CBC:
[pegar transcripción]
Material de apoyo: Capítulo 2 de Serway "Movimiento en una dimensión":
[pegar texto del capítulo]
Incluí:
- Definiciones de posición, velocidad, aceleración
- Fórmulas del MRU y MRUV
- Diagramas de movimiento con TikZ
- Gráficos posición-tiempo y velocidad-tiempo
- Ejemplos resueltos paso a paso
- Glosario de términos
```

134
list_notion_pages.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Script para listar todas las páginas y bases de datos accesibles
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from config import settings
from notion_client import Client
def main():
print("\n" + "=" * 70)
print("📚 LISTANDO TODAS LAS PÁGINAS Y BASES DE DATOS")
print("=" * 70 + "\n")
token = settings.NOTION_API_TOKEN
client = Client(auth=token)
try:
# Buscar todas las páginas sin filtro
print("🔍 Buscando todas las páginas accesibles...\n")
results = client.search(page_size=100)
all_items = results.get("results", [])
# Separar bases de datos y páginas
databases = [item for item in all_items if item.get("object") == "database"]
pages = [item for item in all_items if item.get("object") == "page"]
print(
f"✅ Encontrados: {len(databases)} base(s) de datos y {len(pages)} página(s)\n"
)
if databases:
print("=" * 70)
print("📊 BASES DE DATOS ENCONTRADAS:")
print("=" * 70)
for i, db in enumerate(databases, 1):
db_id = db.get("id", "N/A")
title_list = db.get("title", [])
title = (
title_list[0].get("plain_text", "Sin título")
if title_list
else "Sin título"
)
print(f"\n🔷 {i}. {title}")
print(f" ID: {db_id}")
print(f" URL: https://notion.so/{db_id.replace('-', '')}")
# Mostrar propiedades
props = db.get("properties", {})
if props:
print(f" Propiedades:")
for prop_name, prop_data in list(props.items())[:5]:
prop_type = prop_data.get("type", "unknown")
print(f"{prop_name} ({prop_type})")
if len(props) > 5:
print(f" ... y {len(props) - 5} más")
print("-" * 70)
if pages:
print("\n" + "=" * 70)
print("📄 PÁGINAS ENCONTRADAS:")
print("=" * 70)
for i, page in enumerate(pages, 1):
page_id = page.get("id", "N/A")
# Intentar obtener el título
title = "Sin título"
props = page.get("properties", {})
# Buscar en diferentes ubicaciones del título
if "title" in props:
title_prop = props["title"]
if "title" in title_prop:
title_list = title_prop["title"]
if title_list:
title = title_list[0].get("plain_text", "Sin título")
elif "Name" in props:
name_prop = props["Name"]
if "title" in name_prop:
title_list = name_prop["title"]
if title_list:
title = title_list[0].get("plain_text", "Sin título")
print(f"\n🔷 {i}. {title}")
print(f" ID: {page_id}")
print(f" URL: https://notion.so/{page_id.replace('-', '')}")
print("-" * 70)
if databases:
print("\n" + "=" * 70)
print("💡 SIGUIENTE PASO:")
print("=" * 70)
print("\nSi 'CBC' aparece arriba como BASE DE DATOS:")
print("1. Copia el ID de la base de datos 'CBC'")
print("2. Actualiza tu .env:")
print(" NOTION_DATABASE_ID=<el_id_completo>")
print("\nSi 'CBC' aparece como PÁGINA:")
print("1. Abre la página en Notion")
print("2. Busca una base de datos dentro de esa página")
print("3. Haz click en '...' de la base de datos")
print("4. Selecciona 'Copy link to view'")
print("5. El ID estará en el URL copiado")
print("\n4. Ejecuta: python test_notion_integration.py\n")
else:
print("\n⚠️ No se encontraron bases de datos accesibles.")
print("\n📋 OPCIONES:")
print("\n1. Crear una nueva base de datos:")
print(" - Abre una de las páginas listadas arriba")
print(" - Crea una tabla/database dentro")
print(" - Copia el ID de esa base de datos")
print("\n2. O comparte una base de datos existente:")
print(" - Abre tu base de datos 'CBC' en Notion")
print(" - Click en '...' > 'Connections'")
print(" - Agrega tu integración\n")
except Exception as e:
print(f"❌ Error: {e}\n")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

310
main.py
View File

@@ -3,6 +3,7 @@
CBCFacil - Main Service Entry Point CBCFacil - Main Service Entry Point
Unified AI service for document processing (audio, PDF, text) Unified AI service for document processing (audio, PDF, text)
""" """
import logging import logging
import sys import sys
import time import time
@@ -16,12 +17,14 @@ from typing import Optional
# Load environment variables from .env file # Load environment variables from .env file
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Configure logging with JSON formatter for production # Configure logging with JSON formatter for production
class JSONFormatter(logging.Formatter): class JSONFormatter(logging.Formatter):
"""JSON formatter for structured logging in production""" """JSON formatter for structured logging in production"""
def format(self, record): def format(self, record):
log_entry = { log_entry = {
"timestamp": datetime.utcnow().isoformat() + "Z", "timestamp": datetime.utcnow().isoformat() + "Z",
@@ -29,43 +32,43 @@ class JSONFormatter(logging.Formatter):
"message": record.getMessage(), "message": record.getMessage(),
"module": record.module, "module": record.module,
"function": record.funcName, "function": record.funcName,
"line": record.lineno "line": record.lineno,
} }
# Add exception info if present # Add exception info if present
if record.exc_info: if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info) log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry) return json.dumps(log_entry)
def setup_logging() -> logging.Logger: def setup_logging() -> logging.Logger:
"""Setup logging configuration""" """Setup logging configuration"""
from config import settings from config import settings
# Create logger # Create logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper())) logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper()))
# Remove existing handlers # Remove existing handlers
logger.handlers.clear() logger.handlers.clear()
# Console handler # Console handler
console_handler = logging.StreamHandler(sys.stdout) console_handler = logging.StreamHandler(sys.stdout)
if settings.is_production: if settings.is_production:
console_handler.setFormatter(JSONFormatter()) console_handler.setFormatter(JSONFormatter())
else: else:
console_handler.setFormatter(logging.Formatter( console_handler.setFormatter(
"%(asctime)s [%(levelname)s] - %(name)s - %(message)s" logging.Formatter("%(asctime)s [%(levelname)s] - %(name)s - %(message)s")
)) )
logger.addHandler(console_handler) logger.addHandler(console_handler)
# File handler if configured # File handler if configured
if settings.LOG_FILE: if settings.LOG_FILE:
file_handler = logging.FileHandler(settings.LOG_FILE) file_handler = logging.FileHandler(settings.LOG_FILE)
file_handler.setFormatter(JSONFormatter()) file_handler.setFormatter(JSONFormatter())
logger.addHandler(file_handler) logger.addHandler(file_handler)
return logger return logger
@@ -74,9 +77,12 @@ logger = setup_logging()
def acquire_lock() -> int: def acquire_lock() -> int:
"""Acquire single instance lock""" """Acquire single instance lock"""
lock_file = Path(os.getenv("LOCAL_STATE_DIR", str(Path(__file__).parent))) / ".main_service.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_file.parent.mkdir(parents=True, exist_ok=True)
lock_fd = open(lock_file, 'w') lock_fd = open(lock_file, "w")
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_fd.write(str(os.getpid())) lock_fd.write(str(os.getpid()))
lock_fd.flush() lock_fd.flush()
@@ -96,11 +102,13 @@ def release_lock(lock_fd) -> None:
def validate_configuration() -> None: def validate_configuration() -> None:
"""Validate configuration at startup""" """Validate configuration at startup"""
from config.validators import validate_environment, ConfigurationError from config.validators import validate_environment, ConfigurationError
try: try:
warnings = validate_environment() warnings = validate_environment()
if warnings: if warnings:
logger.info(f"Configuration validation completed with {len(warnings)} warnings") logger.info(
f"Configuration validation completed with {len(warnings)} warnings"
)
except ConfigurationError as e: except ConfigurationError as e:
logger.error(f"Configuration validation failed: {e}") logger.error(f"Configuration validation failed: {e}")
raise raise
@@ -113,13 +121,13 @@ def check_service_health() -> dict:
""" """
from config import settings from config import settings
from services.webdav_service import webdav_service from services.webdav_service import webdav_service
health_status = { health_status = {
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
"status": "healthy", "status": "healthy",
"services": {} "services": {},
} }
# Check WebDAV # Check WebDAV
try: try:
if settings.has_webdav_config: if settings.has_webdav_config:
@@ -129,15 +137,13 @@ def check_service_health() -> dict:
else: else:
health_status["services"]["webdav"] = {"status": "not_configured"} health_status["services"]["webdav"] = {"status": "not_configured"}
except Exception as e: except Exception as e:
health_status["services"]["webdav"] = { health_status["services"]["webdav"] = {"status": "unhealthy", "error": str(e)}
"status": "unhealthy",
"error": str(e)
}
health_status["status"] = "degraded" health_status["status"] = "degraded"
# Check Telegram # Check Telegram
try: try:
from services.telegram_service import telegram_service from services.telegram_service import telegram_service
if telegram_service.is_configured: if telegram_service.is_configured:
health_status["services"]["telegram"] = {"status": "healthy"} health_status["services"]["telegram"] = {"status": "healthy"}
else: else:
@@ -145,23 +151,21 @@ def check_service_health() -> dict:
except Exception as e: except Exception as e:
health_status["services"]["telegram"] = { health_status["services"]["telegram"] = {
"status": "unavailable", "status": "unavailable",
"error": str(e) "error": str(e),
} }
# Check VRAM manager # Check VRAM manager
try: try:
from services.vram_manager import vram_manager from services.vram_manager import vram_manager
vram_info = vram_manager.get_vram_info() vram_info = vram_manager.get_vram_info()
health_status["services"]["vram"] = { health_status["services"]["vram"] = {
"status": "healthy", "status": "healthy",
"available_gb": vram_info.get("free", 0) / (1024**3) "available_gb": vram_info.get("free", 0) / (1024**3),
} }
except Exception as e: except Exception as e:
health_status["services"]["vram"] = { health_status["services"]["vram"] = {"status": "unavailable", "error": str(e)}
"status": "unavailable",
"error": str(e)
}
return health_status return health_status
@@ -172,29 +176,45 @@ def initialize_services() -> None:
from services.vram_manager import vram_manager from services.vram_manager import vram_manager
from services.telegram_service import telegram_service from services.telegram_service import telegram_service
from storage.processed_registry import processed_registry from storage.processed_registry import processed_registry
logger.info("Initializing services...") logger.info("Initializing services...")
# Validate configuration # Validate configuration
validate_configuration() validate_configuration()
# Warn if WebDAV not configured # Warn if WebDAV not configured
if not settings.has_webdav_config: if not settings.has_webdav_config:
logger.warning("WebDAV not configured - file sync functionality disabled") logger.warning("WebDAV not configured - file sync functionality disabled")
# Warn if AI providers not configured # Warn if AI providers not configured
if not settings.has_ai_config: if not settings.has_ai_config:
logger.warning("AI providers not configured - summary generation will not work") logger.warning("AI providers not configured - summary generation will not work")
# Configure Telegram if credentials available # Configure Telegram if credentials available
if settings.TELEGRAM_TOKEN and settings.TELEGRAM_CHAT_ID: if settings.TELEGRAM_TOKEN and settings.TELEGRAM_CHAT_ID:
try: try:
telegram_service.configure(settings.TELEGRAM_TOKEN, settings.TELEGRAM_CHAT_ID) telegram_service.configure(
settings.TELEGRAM_TOKEN, settings.TELEGRAM_CHAT_ID
)
telegram_service.send_start_notification() telegram_service.send_start_notification()
logger.info("Telegram notifications enabled") logger.info("Telegram notifications enabled")
except Exception as e: except Exception as e:
logger.error(f"Failed to configure Telegram: {e}") logger.error(f"Failed to configure Telegram: {e}")
# Configure Notion if credentials available
if settings.has_notion_config:
try:
from services.notion_service import notion_service
notion_service.configure(
settings.NOTION_API_TOKEN, settings.NOTION_DATABASE_ID
)
logger.info("✅ Notion integration enabled")
except Exception as e:
logger.error(f"Failed to configure Notion: {e}")
else:
logger.info("Notion not configured - upload to Notion disabled")
# Initialize WebDAV if configured # Initialize WebDAV if configured
if settings.has_webdav_config: if settings.has_webdav_config:
try: try:
@@ -205,7 +225,7 @@ def initialize_services() -> None:
logger.exception("WebDAV initialization error details") logger.exception("WebDAV initialization error details")
else: else:
logger.info("Skipping WebDAV initialization (not configured)") logger.info("Skipping WebDAV initialization (not configured)")
# Initialize VRAM manager # Initialize VRAM manager
try: try:
vram_manager.initialize() vram_manager.initialize()
@@ -213,7 +233,7 @@ def initialize_services() -> None:
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize VRAM manager: {e}") logger.error(f"Failed to initialize VRAM manager: {e}")
logger.exception("VRAM manager initialization error details") logger.exception("VRAM manager initialization error details")
# Initialize processed registry # Initialize processed registry
try: try:
processed_registry.initialize() processed_registry.initialize()
@@ -221,11 +241,11 @@ def initialize_services() -> None:
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize processed registry: {e}") logger.error(f"Failed to initialize processed registry: {e}")
logger.exception("Registry initialization error details") logger.exception("Registry initialization error details")
# Run health check # Run health check
health = check_service_health() health = check_service_health()
logger.info(f"Initial health check: {json.dumps(health, indent=2)}") logger.info(f"Initial health check: {json.dumps(health, indent=2)}")
logger.info("All services initialized successfully") logger.info("All services initialized successfully")
@@ -233,6 +253,7 @@ def send_error_notification(error_type: str, error_message: str) -> None:
"""Send error notification via Telegram""" """Send error notification via Telegram"""
try: try:
from services.telegram_service import telegram_service from services.telegram_service import telegram_service
if telegram_service.is_configured: if telegram_service.is_configured:
telegram_service.send_error_notification(error_type, error_message) telegram_service.send_error_notification(error_type, error_message)
except Exception as e: except Exception as e:
@@ -243,15 +264,16 @@ def run_dashboard_thread() -> None:
"""Run Flask dashboard in a separate thread""" """Run Flask dashboard in a separate thread"""
try: try:
from api.routes import create_app from api.routes import create_app
app = create_app() app = create_app()
# Run Flask in production mode with threaded=True # Run Flask in production mode with threaded=True
app.run( app.run(
host='0.0.0.0', host="0.0.0.0",
port=5000, port=5000,
debug=False, debug=False,
threaded=True, threaded=True,
use_reloader=False # Important: disable reloader in thread use_reloader=False, # Important: disable reloader in thread
) )
except Exception as e: except Exception as e:
logger.error(f"Dashboard thread error: {e}") logger.error(f"Dashboard thread error: {e}")
@@ -260,14 +282,12 @@ def run_dashboard_thread() -> None:
def start_dashboard() -> threading.Thread: def start_dashboard() -> threading.Thread:
"""Start dashboard in a background daemon thread""" """Start dashboard in a background daemon thread"""
dashboard_port = int(os.getenv('DASHBOARD_PORT', '5000')) dashboard_port = int(os.getenv("DASHBOARD_PORT", "5000"))
logger.info(f"Starting dashboard on port {dashboard_port}...") logger.info(f"Starting dashboard on port {dashboard_port}...")
# Create daemon thread so it doesn't block shutdown # Create daemon thread so it doesn't block shutdown
dashboard_thread = threading.Thread( dashboard_thread = threading.Thread(
target=run_dashboard_thread, target=run_dashboard_thread, name="DashboardThread", daemon=True
name="DashboardThread",
daemon=True
) )
dashboard_thread.start() dashboard_thread.start()
logger.info(f"Dashboard thread started (Thread-ID: {dashboard_thread.ident})") logger.info(f"Dashboard thread started (Thread-ID: {dashboard_thread.ident})")
@@ -282,109 +302,173 @@ def run_main_loop() -> None:
from processors.audio_processor import AudioProcessor from processors.audio_processor import AudioProcessor
from processors.pdf_processor import PDFProcessor from processors.pdf_processor import PDFProcessor
from processors.text_processor import TextProcessor from processors.text_processor import TextProcessor
audio_processor = AudioProcessor() audio_processor = AudioProcessor()
pdf_processor = PDFProcessor() pdf_processor = PDFProcessor()
text_processor = TextProcessor() text_processor = TextProcessor()
consecutive_errors = 0 consecutive_errors = 0
max_consecutive_errors = 5 max_consecutive_errors = 5
while True: while True:
try: try:
logger.info("--- Polling for new files ---") logger.info("--- Polling for new files ---")
processed_registry.load() processed_registry.load()
# Process PDFs # Process PDFs
if settings.has_webdav_config: if settings.has_webdav_config:
try: try:
webdav_service.mkdir(settings.REMOTE_PDF_FOLDER) webdav_service.mkdir(settings.REMOTE_PDF_FOLDER)
pdf_files = webdav_service.list(settings.REMOTE_PDF_FOLDER) pdf_files = webdav_service.list(settings.REMOTE_PDF_FOLDER)
for file_path in pdf_files: for file_path in pdf_files:
if file_path.lower().endswith('.pdf'): if file_path.lower().endswith(".pdf"):
if not processed_registry.is_processed(file_path): if not processed_registry.is_processed(file_path):
pdf_processor.process(file_path) from pathlib import Path
from urllib.parse import unquote
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 PDF detectado: {local_filename}\n"
f"⬇️ Descargando..."
)
logger.info(
f"Downloading PDF: {file_path} -> {local_path}"
)
webdav_service.download(file_path, local_path)
# Step 2: Process PDF
telegram_service.send_message(
f"🔍 Procesando PDF con OCR..."
)
pdf_processor.process(str(local_path))
processed_registry.save(file_path) processed_registry.save(file_path)
except Exception as e: except Exception as e:
logger.exception(f"Error processing PDFs: {e}") logger.exception(f"Error processing PDFs: {e}")
send_error_notification("pdf_processing", str(e)) send_error_notification("pdf_processing", str(e))
# Process Audio files # Process Audio files
if settings.has_webdav_config: if settings.has_webdav_config:
try: try:
audio_files = webdav_service.list(settings.REMOTE_AUDIOS_FOLDER) audio_files = webdav_service.list(settings.REMOTE_AUDIOS_FOLDER)
for file_path in audio_files: for file_path in audio_files:
if any(file_path.lower().endswith(ext) for ext in settings.AUDIO_EXTENSIONS): if any(
file_path.lower().endswith(ext)
for ext in settings.AUDIO_EXTENSIONS
):
if not processed_registry.is_processed(file_path): if not processed_registry.is_processed(file_path):
from pathlib import Path from pathlib import Path
from urllib.parse import unquote from urllib.parse import unquote
from document.generators import DocumentGenerator from document.generators import DocumentGenerator
from services.telegram_service import telegram_service from services.telegram_service import telegram_service
local_filename = unquote(Path(file_path).name) local_filename = unquote(Path(file_path).name)
base_name = Path(local_filename).stem base_name = Path(local_filename).stem
local_path = settings.LOCAL_DOWNLOADS_PATH / local_filename local_path = (
settings.LOCAL_DOWNLOADS_PATH.mkdir(parents=True, exist_ok=True) settings.LOCAL_DOWNLOADS_PATH / local_filename
)
settings.LOCAL_DOWNLOADS_PATH.mkdir(
parents=True, exist_ok=True
)
# Step 1: Notify and download # Step 1: Notify and download
telegram_service.send_message( telegram_service.send_message(
f"🎵 Nuevo audio detectado: {local_filename}\n" f"🎵 Nuevo audio detectado: {local_filename}\n"
f"⬇️ Descargando..." f"⬇️ Descargando..."
) )
logger.info(f"Downloading audio: {file_path} -> {local_path}") logger.info(
f"Downloading audio: {file_path} -> {local_path}"
)
webdav_service.download(file_path, local_path) webdav_service.download(file_path, local_path)
# Step 2: Transcribe # Step 2: Transcribe
telegram_service.send_message(f"📝 Transcribiendo audio con Whisper...") telegram_service.send_message(
f"📝 Transcribiendo audio con Whisper..."
)
result = audio_processor.process(str(local_path)) result = audio_processor.process(str(local_path))
if result.get("success") and result.get("transcription_path"): if result.get("success") and result.get(
transcription_file = Path(result["transcription_path"]) "transcription_path"
transcription_text = result.get("text", "") ):
transcription_file = Path(
# Step 3: Generate AI summary and documents result["transcription_path"]
telegram_service.send_message(f"🤖 Generando resumen con IA...")
doc_generator = DocumentGenerator()
success, summary, output_files = doc_generator.generate_summary(
transcription_text, base_name
) )
transcription_text = result.get("text", "")
# Step 3: Generate AI summary and documents
telegram_service.send_message(
f"🤖 Generando resumen académico LaTeX..."
)
doc_generator = DocumentGenerator(
notification_callback=lambda msg: telegram_service.send_message(msg)
)
success, summary, output_files = (
doc_generator.generate_summary(
transcription_text, base_name
)
)
# Step 4: Upload all files to Nextcloud # Step 4: Upload all files to Nextcloud
if success and output_files: if success and output_files:
# Create folders # Create folders
for folder in [settings.RESUMENES_FOLDER, settings.DOCX_FOLDER]: for folder in [
settings.RESUMENES_FOLDER,
settings.DOCX_FOLDER,
]:
try: try:
webdav_service.makedirs(folder) webdav_service.makedirs(folder)
except Exception: except Exception:
pass pass
# Upload all files in parallel using batch upload
upload_tasks = []
# Upload transcription TXT # Upload transcription TXT
if transcription_file.exists(): if transcription_file.exists():
remote_txt = f"{settings.RESUMENES_FOLDER}/{transcription_file.name}" remote_txt = f"{settings.RESUMENES_FOLDER}/{transcription_file.name}"
webdav_service.upload(transcription_file, remote_txt) upload_tasks.append((transcription_file, remote_txt))
logger.info(f"Uploaded: {remote_txt}")
# Upload DOCX # Upload DOCX
docx_path = Path(output_files.get('docx_path', '')) docx_path = Path(
output_files.get("docx_path", "")
)
if docx_path.exists(): if docx_path.exists():
remote_docx = f"{settings.DOCX_FOLDER}/{docx_path.name}" remote_docx = f"{settings.DOCX_FOLDER}/{docx_path.name}"
webdav_service.upload(docx_path, remote_docx) upload_tasks.append((docx_path, remote_docx))
logger.info(f"Uploaded: {remote_docx}")
# Upload PDF # Upload PDF
pdf_path = Path(output_files.get('pdf_path', '')) pdf_path = Path(
output_files.get("pdf_path", "")
)
if pdf_path.exists(): if pdf_path.exists():
remote_pdf = f"{settings.DOCX_FOLDER}/{pdf_path.name}" remote_pdf = f"{settings.DOCX_FOLDER}/{pdf_path.name}"
webdav_service.upload(pdf_path, remote_pdf) upload_tasks.append((pdf_path, remote_pdf))
logger.info(f"Uploaded: {remote_pdf}")
# Upload Markdown # Upload Markdown
md_path = Path(output_files.get('markdown_path', '')) md_path = Path(
output_files.get("markdown_path", "")
)
if md_path.exists(): if md_path.exists():
remote_md = f"{settings.RESUMENES_FOLDER}/{md_path.name}" remote_md = f"{settings.RESUMENES_FOLDER}/{md_path.name}"
webdav_service.upload(md_path, remote_md) upload_tasks.append((md_path, remote_md))
logger.info(f"Uploaded: {remote_md}")
# Execute parallel uploads
if upload_tasks:
upload_results = webdav_service.upload_batch(
upload_tasks, max_workers=4, timeout=120
)
logger.info(f"Parallel upload complete: {len(upload_results)} files")
# Final notification # Final notification
telegram_service.send_message( telegram_service.send_message(
f"✅ Audio procesado: {local_filename}\n" f"✅ Audio procesado: {local_filename}\n"
@@ -396,46 +480,53 @@ def run_main_loop() -> None:
# Just upload transcription if summary failed # Just upload transcription if summary failed
if transcription_file.exists(): if transcription_file.exists():
try: try:
webdav_service.makedirs(settings.RESUMENES_FOLDER) webdav_service.makedirs(
settings.RESUMENES_FOLDER
)
except Exception: except Exception:
pass pass
remote_txt = f"{settings.RESUMENES_FOLDER}/{transcription_file.name}" remote_txt = f"{settings.RESUMENES_FOLDER}/{transcription_file.name}"
webdav_service.upload(transcription_file, remote_txt) webdav_service.upload(
transcription_file, remote_txt
)
telegram_service.send_message( telegram_service.send_message(
f"⚠️ Resumen fallido, solo transcripción subida:\n{transcription_file.name}" f"⚠️ Resumen fallido, solo transcripción subida:\n{transcription_file.name}"
) )
processed_registry.save(file_path) processed_registry.save(file_path)
except Exception as e: except Exception as e:
logger.exception(f"Error processing audio: {e}") logger.exception(f"Error processing audio: {e}")
send_error_notification("audio_processing", str(e)) send_error_notification("audio_processing", str(e))
# Process Text files # Process Text files
if settings.has_webdav_config: if settings.has_webdav_config:
try: try:
text_files = webdav_service.list(settings.REMOTE_TXT_FOLDER) text_files = webdav_service.list(settings.REMOTE_TXT_FOLDER)
for file_path in text_files: for file_path in text_files:
if any(file_path.lower().endswith(ext) for ext in settings.TXT_EXTENSIONS): if any(
file_path.lower().endswith(ext)
for ext in settings.TXT_EXTENSIONS
):
if not processed_registry.is_processed(file_path): if not processed_registry.is_processed(file_path):
text_processor.process(file_path) text_processor.process(file_path)
processed_registry.save(file_path) processed_registry.save(file_path)
except Exception as e: except Exception as e:
logger.exception(f"Error processing text: {e}") logger.exception(f"Error processing text: {e}")
send_error_notification("text_processing", str(e)) send_error_notification("text_processing", str(e))
# Reset error counter on success # Reset error counter on success
consecutive_errors = 0 consecutive_errors = 0
except Exception as e: except Exception as e:
# Improved error logging with full traceback # Improved error logging with full traceback
logger.exception(f"Critical error in main loop: {e}") logger.exception(f"Critical error in main loop: {e}")
# Send notification for critical errors # Send notification for critical errors
send_error_notification("main_loop", str(e)) send_error_notification("main_loop", str(e))
# Track consecutive errors # Track consecutive errors
consecutive_errors += 1 consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors: if consecutive_errors >= max_consecutive_errors:
logger.critical( logger.critical(
f"Too many consecutive errors ({consecutive_errors}). " f"Too many consecutive errors ({consecutive_errors}). "
@@ -443,14 +534,14 @@ def run_main_loop() -> None:
) )
send_error_notification( send_error_notification(
"consecutive_errors", "consecutive_errors",
f"Service has failed {consecutive_errors} consecutive times" f"Service has failed {consecutive_errors} consecutive times",
) )
# Don't exit, let the loop continue with backoff # Don't exit, let the loop continue with backoff
logger.info(f"Waiting {settings.POLL_INTERVAL * 2} seconds before retry...") logger.info(f"Waiting {settings.POLL_INTERVAL * 2} seconds before retry...")
time.sleep(settings.POLL_INTERVAL * 2) time.sleep(settings.POLL_INTERVAL * 2)
continue continue
logger.info(f"Cycle completed. Waiting {settings.POLL_INTERVAL} seconds...") logger.info(f"Cycle completed. Waiting {settings.POLL_INTERVAL} seconds...")
time.sleep(settings.POLL_INTERVAL) time.sleep(settings.POLL_INTERVAL)
@@ -462,7 +553,9 @@ def main():
try: try:
logger.info("=== CBCFacil Service Started ===") logger.info("=== CBCFacil Service Started ===")
logger.info(f"Version: {os.getenv('APP_VERSION', '8.0')}") logger.info(f"Version: {os.getenv('APP_VERSION', '8.0')}")
logger.info(f"Environment: {'production' if os.getenv('DEBUG', 'false').lower() != 'true' else 'development'}") logger.info(
f"Environment: {'production' if os.getenv('DEBUG', 'false').lower() != 'true' else 'development'}"
)
lock_fd = acquire_lock() lock_fd = acquire_lock()
initialize_services() initialize_services()
@@ -472,7 +565,7 @@ def main():
# Run main processing loop # Run main processing loop
run_main_loop() run_main_loop()
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Shutdown requested by user") logger.info("Shutdown requested by user")
except Exception as e: except Exception as e:
@@ -491,12 +584,15 @@ if __name__ == "__main__":
command = sys.argv[1] command = sys.argv[1]
if command == "whisper" and len(sys.argv) == 4: if command == "whisper" and len(sys.argv) == 4:
from processors.audio_processor import AudioProcessor from processors.audio_processor import AudioProcessor
AudioProcessor().process(sys.argv[2]) AudioProcessor().process(sys.argv[2])
elif command == "pdf" and len(sys.argv) == 4: elif command == "pdf" and len(sys.argv) == 4:
from processors.pdf_processor import PDFProcessor from processors.pdf_processor import PDFProcessor
PDFProcessor().process(sys.argv[2]) PDFProcessor().process(sys.argv[2])
elif command == "health": elif command == "health":
from main import check_service_health from main import check_service_health
health = check_service_health() health = check_service_health()
print(json.dumps(health, indent=2)) print(json.dumps(health, indent=2))
else: else:

2447
opus.md Normal file

File diff suppressed because it is too large Load Diff

10
restart_service.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# Detener servicio existente
pkill -f "python main.py"
sleep 2
# Reiniciar con log visible
cd /home/ren/proyectos/cbc
source .venv/bin/activate
python main.py >> main.log 2>&1 &
echo "Servicio reiniciado. Ver logs con: tail -f main.log"

953
resumen_curiosidades.tex Normal file
View File

@@ -0,0 +1,953 @@
\documentclass[11pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[spanish,provide=*]{babel}
\usepackage{amsmath,amssymb}
\usepackage{geometry}
\usepackage{graphicx}
\usepackage{tikz}
\usetikzlibrary{arrows.meta,positioning,shapes.geometric,calc,shapes.misc}
\usepackage{booktabs}
\usepackage{enumitem}
\usepackage{fancyhdr}
\usepackage{titlesec}
\usepackage{tcolorbox}
\usepackage{array}
\usepackage{multirow}
\usepackage{csquotes}
\usepackage{pgfplots}
\pgfplotsset{compat=1.18}
\geometry{margin=2.5cm}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{Curiosidades Científicas y Culturales}
\fancyhead[R]{Compilación Interdisciplinaria}
\fancyfoot[C]{\thepage}
% Cajas para destacar contenido
\newtcolorbox{definicion}[1][]{
colback=blue!5!white,
colframe=blue!75!black,
fonttitle=\bfseries,
title=#1,
sharp corners=downhill
}
\newtcolorbox{importante}[1][]{
colback=red!5!white,
colframe=red!75!black,
fonttitle=\bfseries,
title=#1,
sharp corners=downhill
}
\newtcolorbox{ejemplo}[1][]{
colback=green!5!white,
colframe=green!50!black,
fonttitle=\bfseries,
title=#1,
sharp corners=downhill
}
\newtcolorbox{dato}[1][]{
colback=yellow!5!white,
colframe=orange!75!black,
fonttitle=\bfseries,
title=#1,
sharp corners=downhill
}
\newtcolorbox{formula}[1][]{
colback=purple!5!white,
colframe=purple!75!black,
fonttitle=\bfseries,
title=#1,
sharp corners=downhill
}
\title{\textbf{25 Cosas que No Sabías Hace Cinco Minutos}\\[0.5cm]
\large{Un compendio interdisciplinario de curiosidades científicas,\\fenómenos culturales y avances tecnológicos}}
\author{Compilación Académica}
\date{\today}
\begin{document}
\maketitle
\thispagestyle{empty}
\tableofcontents
\newpage
\section{Introducción}
El conocimiento humano se caracteriza por su naturaleza fragmentada y especializada. Sin embargo, algunas de las comprensiones más valiosas surgen precisamente de la \textbf{interconexión entre disciplinas} aparentemente dispares. El presente documento compila veinticinco curiosidades que abarcan desde la gastronomía francesa hasta la bioacústica marina, desde la psicología del consumidor hasta la ingeniería de materiales, demostrando que el curiosity-driven learning --el aprendizaje impulsado por la curiosidad-- representa una de las formas más efectivas de adquirir conocimiento interconectado.
\begin{importante}[Enfoque Interdisciplinario]
Este documento está organizado para mostrar conexiones entre áreas tradicionalmente separadas del conocimiento. Cada curiosidad sirve como punto de entrada para explorar conceptos más profundos en física, biología, psicología, ingeniería y cultura.
\end{importante}
\section{Gastronomía y Cultura Alimentaria}
\subsection{El Hot Dog Francés: Adaptación Culinaría Transcultural}
\begin{definicion}[Hot Dog Francés]
Variante gastronómica del hot dog tradicional estadounidense que incorpora técnicas de panadería francesa y métodos de preparación distintivos.
\end{definicion}
\textbf{Diferenciación técnica con el hot dog tradicional:}
\begin{table}[h]
\centering
\begin{tabular}{@{}p{5cm}@{}p{5cm}@{}}
\toprule
\textbf{Hot Dog Tradicional} & \textbf{Hot Dog Francés} \\ \midrule
Pan blando de forma alargada & Baguette crujiente \\
Corte longitudinal del pan & Perforación central del pan \\
Salsas aplicadas externamente & Salsas inyectadas internamente \\
Queso cheddar o americano & Emmental o Gruyère rallado \\
Ensamblaje lineal & Técnica de relleno tubular \\ \bottomrule
\end{tabular}
\caption{Comparación técnica entre hot dog tradicional y francés}
\end{table}
\textbf{Proceso de preparación:}
\begin{enumerate}
\item \textbf{Selección del pan}: Baguette fresca, crujiente externamente, suave internamente
\item \textbf{Perforación}: Se crea un túnel central utilizando utensilio especializado
\item \textbf{Inyección de salsas}: Mayonesa, mostaza Dijon o variaciones regionales
\item \textbf{Inserción de salchicha}: Generalmente salchicha tipo Toulouse o similar
\item \textbf{Aplicación de queso}: Emmental o Gruyère rallado, parcialmente derretido
\end{enumerate}
\begin{ejemplo}[Contexto Cultural]
Este plato ilustra el concepto de \textit{glocalización} --adaptación de productos globales a preferencias locales-- donde el concepto estadounidense de hot dog se hibrida con la tradición panadera francesa de la baguette, creando un producto único que mantiene elementos de ambas culturas.
\end{ejemplo}
\subsection{Heinz Tomato Ketchup Smoothie: Innovación y Controversia}
\begin{dato}[Heinz Tomato Ketchup Smoothie]
Bebida desarrollada por Heinz que combina ketchup con ingredientes frutales, destacando la naturaleza botánica del tomate como fruta.
\end{dato}
\textbf{Composición y análisis:}
\begin{table}[h]
\centering
\begin{tabular}{@{}ll@{}}
\toprule
\textbf{Ingrediente} & \textbf{Función en la Bebida} \\ \midrule
Ketchup & Base, sabor ácido-dulce característico \\
Sorbete de açaí & Textura, antioxidantes, color púrpura \\
Jugo de manzana & Dulzor natural, líquido base \\
Fresas & Sabor frutal complementario, color \\
Frambuesas & Acidez, notas frutales, vitaminas \\ \bottomrule
\end{tabular}
\end{table}
\begin{definicion}[Solanum lycopersicum]
Nombre científico del tomate, botánicamente clasificado como una baya (fruta) aunque culinariamente tratado como vegetal. Esta ambigüedad clasificatoria permite la creación de productos que desafían las categorías culinarias tradicionales.
\end{definicion}
\textbf{Análisis desde la teoría de marketing:}
Este producto representa una estrategia de \textbf{diferenciación por extinción} --crear productos tan inusuales que generen conversación y cobertura mediática--, convirtiendo la controversial naturaleza del producto en su principal característica de marketing.
\section{Biología, Salud y Medicina}
\subsection{Propiedades Odontológicas del Brócoli}
\begin{importante}[Propiedad Antibacteriana]
El consumo regular de brócoli contribuye a la reducción de placa dental mediante la inhibición de \textit{Streptococcus mutans}, la bacteria primarily responsable de caries y enfermedad periodontal.
\end{importante}
\textbf{Mecanismo bioquímico de acción:}
\begin{itemize}
\item \textbf{Sulforafano}: Compuesto azufrado con propiedades antibacterianas
\item \textbf{Fibra mecánica}: Acción limpiadora abrasiva durante masticación
\item \textbf{Isothiocyanatos}: Modificación del pH bucal hacia ambientes menos favorables para bacterias cariogénicas
\item \textbf{Antioxidantes}: Protección del esmalte dental
\end{itemize}
\begin{definicion}[Streptococcus mutans]
Bacteria grampositiva, anaerobia facultativa, considerada el principal agente etiológico de caries dental en humanos. Su capacidad para formar biopelículas (placa) y metabolizar carbohidratos produciendo ácido láctico la hace particularmente patogénica para la estructura dental.
\end{definicion}
\textbf{Proceso de formación de placa:}
\begin{center}
\begin{tikzpicture}[
node distance=1.5cm,
process/.style={rectangle, draw, fill=blue!10, text width=3cm, align=center, rounded corners},
arrow/.style={-Stealth, thick}
]
\node[process] (bacterias) {Colonización de $S. mutans$};
\node[process, right=of bacterias] (biofilm) {Formación de biopelícula};
\node[process, right=of biofilm] (acido) {Producción de ácido};
\node[process, right=of acido, fill=red!10] (caries) {Demineralización del esmalte};
\draw[arrow] (bacterias) -- (biofilm);
\draw[arrow] (biofilm) -- (acido);
\draw[arrow] (acido) -- (caries);
\end{tikzpicture}
\end{center}
El brócoli interfiere específicamente en la etapa de colonización, reduciendo la capacidad de $S. mutans$ de adherirse a la superficie dental y formar biopelículas cohesivas.
\subsection{Mimetismo en el Pez Murciélago}
\begin{definicion}[Pez Murciélago]
Pez de la familia Ogcocephalidae que en su etapa juvenil desarrolla un camuflaje pasivo imitando hojas flotantes, mecanismo evolutivo de supervivencia en etapas vulnerables del desarrollo.
\end{definicion}
\textbf{Características del mimetismo ontogénico:}
\begin{table}[h]
\centering
\begin{tabular}{@{}p{5cm}@{}p{5cm}@{}}
\toprule
\textbf{Etapa Juvenil} & \textbf{Etapa Adulta} \\ \midrule
Imita hoja flotante & Forma de pez murciélago típica \\
Comportamiento pasivo & Movimiento activo \\
Superficie de agua libre & Aguas profundas \\
Depredación evasiva & Depredación activa \\
Coloración críptica & Coloración aposemática \\ \bottomrule
\end{tabular}
\caption{Dimorfismo ontogénico en pez murciélago}
\end{table}
\begin{ejemplo}[Secuencia de Desarrollo]
\begin{enumerate}
\item \textbf{Eclosión}: Larva planctónica inicial
\item \textbf{Asentamiento}: Migración a superficie, adopción de forma de hoja
\item \textbf{Cripis}: Camuflaje pasivo, deriva con corrientes
\item \textbf{Metamorfosis}: Desarrollo de características adultas
\item \textbf{Transición}: Abandono del disfraz, migración a profundidad
\item \textbf{Madurez}: Patrón de coloración aposemática, estilo de vida bentónico
\end{enumerate}
\end{ejemplo}
\textbf{Ventajas evolutivas del camuflaje ontogénico:}
\begin{itemize}
\item \textbf{Reducción de depredación}: Estadísticamente significativo en etapas vulnerables
\item \textbf{Ahorro energético}: No requiere inversión en fuga o combate
\item \textbf{Aproximación a presas}: Permite acercarse sin detección
\item \textbf{Optimización de recursos}: Energía dirigida a crecimiento en lugar de defensa activa
\end{itemize}
\section{Psicología y Economía Conductual}
\subsection{El Efecto Señuelo: Arquitectura de Choice}
\begin{definicion}[Efecto Señuelo / Decoy Effect]
Sesgo cognitivo donde la presencia de una tercera opción poco atractiva (el señuelo) modifica sistemáticamente las preferencias entre dos opciones principales, dirigiendo la elección hacia la opción preferida por el ofertante.
\end{definicion}
\textbf{Formalización matemática del efecto señuelo:}
Sean $A$, $B$ y $D$ tres opciones, donde $D$ es el señuelo diseñado para favorecer $B$ sobre $A$. El efecto señuelo ocurre cuando:
\begin{equation}
P(B|A,B) < P(B|A,B,D)
\end{equation}
Donde $P(B|A,B)$ es la probabilidad de elegir $B$ cuando solo están presentes $A$ y $B$, y $P(B|A,B,D)$ es la probabilidad de elegir $B$ cuando el señuelo $D$ está presente.
\textbf{Ejemplo paradigmático - Palomitas de cine:}
\begin{table}[h]
\centering
\begin{tabular}{@{}ccc@{}}
\toprule
\textbf{Tamaño} & \textbf{Precio} & \textbf{Precio por unidad} \\
\midrule
Pequeña & \$4.00 & \$0.40/oz \\
Mediana & \$6.50 & \$0.43/oz \\
Grande & \$7.00 & \$0.28/oz \\
\bottomrule
\end{tabular}
\caption{Estructura de precios con señuelo incorporado}
\end{table}
\begin{importante}[Análisis del Disparidad]
La mediana sirve como señuelo porque:
\begin{itemize}
\item Precio por unidad PEOR que la pequeña
\item Solo \$0.50 menos que la grande
\item Casi nadie la compra (propósito no-consumo)
\item Hace que la grande parezca una ``ganga comparativa''
\end{itemize}
\end{importante}
\textbf{Fundamentos cognitivos:}
\begin{enumerate}
\item \textbf{Comparación relativa}: Los humanos evaluamos opciones en contexto, no absolutamente
\item \textbf{Aversión a la pérdida}: No obtener el ``mejor valor'' se percibe como pérdida
\item \textbf{Anclaje}: La mediana sirve como referencia que hace la grande parecer razonable
\item \textbf{Simplificación heurística}: Elegimos la opción que requiere menos justificación cognitiva
\end{enumerate}
\begin{tikzpicture}[
node distance=2cm,
box/.style={rectangle, draw, minimum width=2cm, minimum height=1cm, align=center},
decoy/.style={rectangle, draw, dashed, minimum width=2cm, minimum height=1cm, align=center}
]
\node[box, align=center] (pequena) {Pequeña\\\$4};
\node[decoy, right=1cm of pequena, align=center] (mediana) {Mediana\\\$6.50\\(Señuelo)};
\node[box, right=1cm of mediana, fill=green!20, align=center] (grande) {Grande\\\$7\\(Objetivo)};
\draw[->, thick, red] (mediana) to[bend left] node[above, font=\tiny] {hace atractiva} (grande);
\draw[->, dashed, gray] (pequena) to[bend right] node[below, font=\tiny] {menos valor percibido} (grande);
\end{tikzpicture}
\subsection{Aplicaciones del Efecto Señuelo}
\begin{itemize}
\item \textbf{Suscripciones de software}: Free, Pro (señuelo), Enterprise (objetivo)
\item \textbf{Productos electrónicos}: Modelos intermedios con características específicas para impulsar premium
\item \textbf{Menús de restaurantes}: Platos extremadamente caros que hacen otros parecer razonables
\item \textbf{Billetes de avión}: Clases configuradas para incentivar cierta elección
\item \textbf{Políticas públicas}: Presentación de opciones para dirigir opinión pública
\end{itemize}
\section{Ingeniería y Tecnología}
\subsection{Cubos de Basura Inteligentes: Visión Artificial Aplicada}
\begin{definicion}[Sistema de Predicción de Trayectoria]
Sistema cibernético que integra visión computacional, aprendizaje automático y actuación mecánica para anticipar la trayectoria de objetos en movimiento y posicionarse óptimamente para su recepción.
\end{definicion}
\textbf{Arquitectura del sistema HTX Studio:}
\begin{center}
\begin{tikzpicture}[
sensor/.style={circle, draw, fill=yellow!20, minimum size=1cm},
process/.style={rectangle, draw, fill=blue!10, minimum width=2cm},
actuator/.style={rectangle, draw, fill=green!10, minimum width=2cm},
arrow/.style={-Stealth, thick}
]
\node[sensor] (camera) {Cámara};
\node[process, right=1.5cm of camera, align=center] (vision) {Visión\\Computacional};
\node[process, right=1cm of vision, align=center] (ml) {Machine\\Learning};
\node[process, right=1cm of ml, align=center] (predict) {Predicción\\Trayectoria};
\node[actuator, right=1cm of predict] (motor) {Motor};
\node[below=0.5cm of motor] (trash) {Cubo};
\draw[arrow] (camera) -- (vision);
\draw[arrow] (vision) -- (ml);
\draw[arrow] (ml) -- (predict);
\draw[arrow] (predict) -- (motor);
\draw[arrow] (motor) -- (trash);
\end{tikzpicture}
\end{center}
\textbf{Especificaciones técnicas:}
\begin{table}[h]
\centering
\begin{tabular}{@{}ll@{}}
\toprule
\textbf{Componente} & \textbf{Especificación} \\ \midrule
Sensores & Cámaras de alta velocidad (60+ fps) \\
Procesamiento & GPU integrada para inferencia en tiempo real \\
Algoritmo & Red neuronal convolucional para detección \\
Actuación & Motores DC con encoder de posición \\
Latencia & <100 ms de detección a movimiento \\
Precisión & >90\% en condiciones normales \\ \bottomrule
\end{tabular}
\end{table}
\subsection{Realidad Aumentada Automotriz: Sistema Xpeng}
\begin{dato}[Emotional AR Driving]
El fabricante chino Xpeng implementa realidad aumentada para comunicación emocional entre conductores, permitiendo proyección de emojis virtuales hacia otros vehículos.
\end{dato}
\textbf{Objetivos de diseño:}
\begin{itemize}
\item \textbf{Canalización de agresión}: Alternativa no-física a gestos agresivos
\item \textbf{Seguridad vial}: Reducción de confrontaciones físicas
\item \textbf{Expresión emocional}: Válvula de escape para frustración
\item \textbf{Diferenciación de marca}: Característica distintiva en mercado saturado
\end{itemize}
\textbf{Implementación técnica:}
\begin{itemize}
\item \textbf{Hardware}: Proyectores HUD (Head-Up Display) en parabrisas
\item \textbf{Software}: Sistema de selección gestual o por comandos de voz
\item \textbf{Renderizado}: Emojis superpuestos a visión del mundo real
\item \textbf{Calibración}: Ajuste automático según distancia del vehículo objetivo
\end{itemize}
\subsection{Repulsión Magnética: Campaña Mercedes-Benz}
\begin{ejemplo}[Brilliant Marketing Campaign]
Mercedes-Benz desarrolló carritos de juguete con imanes de repulsión ocultos para demostrar su tecnología de frenados ABS, creando vehículos ``incolisionables'' que paradójicamente fueron rechazados por el público infantil.
\end{ejemplo}
\textbf{Principio físico implementado:}
\begin{formula}[Ley de Repulsión Magnética]
\begin{equation}
F = \frac{\mu q_1 q_2}{4\pi r^2}
\end{equation}
Donde $F$ es la fuerza de repulsión, $\mu$ la permeabilidad magnética, $q_1$ y $q_2$ las cargas magnéticas (polos), y $r$ la distancia entre imanes.
\end{formula}
\textbf{Paradoja de aceptación:}
\begin{itemize}
\item \textbf{Objetivo publicitario}: Demostrar tecnología de prevención de colisiones
\item \textbf{Resultado técnico}: Carritos efectivamente incapaces de colisionar
\item \textbf{Recepción infantil}: Rechazo universal (eliminaba la diversión principal: chocar autos)
\item \textbf{Resultado publicitario}: Campaña viral exitosa a pesar de falla comercial del producto
\end{itemize}
Este caso ilustra la tensión entre \textit{seguridad} y \textit{ludicidad}, mostrando que en productos recreativos, la prevención del comportamiento ``peligroso'' puede eliminar el valor principal del producto.
\subsection{Sensor Láser de Privacidad Computacional}
\begin{definicion}[Sistema de Seguridad por Hilo Láser]
Dispositivo de seguridad informática que utiliza un haz láser invisible como sensor perimetral; cuando el haz es interrumpido por aproximación, el sistema oculta automáticamente el contenido de pantalla.
\end{definicion}
\textbf{Especificaciones técnicas:}
\begin{table}[h]
\centering
\begin{tabular}{@{}ll@{}}
\toprule
\textbf{Parámetro} & \textbf{Especificación} \\ \midrule
Fuente láser & Diodo láser clase 1 (seguro para ojos) \\
Longitud de onda & 650 nm (rojo invisible) \\
Rango de detección & 0.5-2 metros \\
Latencia de respuesta & <50 ms \\
Integración OS & Windows/macOS/Linux \\ \bottomrule
\end{tabular}
\end{table}
\textbf{Aplicaciones:}
\begin{itemize}
\item Oficinas con tráfico peatonal constante
\item Espacios de trabajo compartido
\item Ambientes donde se maneja información sensible
\item Prevención de visualización accidental de contenido confidencial
\end{itemize}
\section{Ciencia de Materiales}
\subsection{Resistencia de Piezas Lego: Efecto del Pigmento}
\begin{importante}[Variación por Color]
La resistencia mecánica de las piezas de Lego varía significativamente según el pigmento utilizado, con diferencias de hasta 80 kg en capacidad de carga entre el amarillo (550 kg) y el blanco (630 kg).
\end{importante}
\textbf{Datos de resistencia por color:}
\begin{table}[h]
\centering
\begin{tabular}{@{}lcc@{}}
\toprule
\textbf{Color} & \textbf{Resistencia (kg)} & \textbf{Variación vs. Base} \\ \midrule
Amarillo & 550 & -12.5\% \\
Negro & 560 & -11.1\% \\
Azul & 565 & -10.3\% \\
Rojo & 600 & -4.8\% \\
Blanco & \textbf{630} & \textbf{Referencia (+0\%)} \\ \bottomrule
\end{tabular}
\caption{Capacidad de carga según pigmento (Datos experimentales)}
\end{table}
\begin{dato}[Explicación Científica]
\begin{itemize}
\item \textbf{Pigmentos intensos} (azul): Introducen imperfecciones cristalinas en el polímero ABS
\item \textbf{Pigmento blanco} (dióxido de titanio): Actúa como filler reforzante
\item \textbf{Mecanismo}: Dióxido de titanio se integra a matriz polimérica aumentando densidad de entrecruzamiento
\item \textbf{Consecuencia}: Piezas blancas consistentemente más resistentes que otras colores
\end{itemize}
\end{dato}
\textbf{Control de calidad Lego:}
\begin{itemize}
\item \textbf{Tasa de defectos}: 18 piezas por millón (0.0018\%)
\item \textbf{Tolerancia dimensional}: $\pm$0.002 mm (2 micras)
\item \textbf{Materiales}: Moldes de acero inoxidable endurecido
\item \textbf{Vida útil de molde}: ~1 millón de ciclos antes de reemplazo
\end{itemize}
\subsection{Resortera Gigante: Mecánica de Proyectiles}
\textbf{Análisis físico del invento de Mike Shake:}
\begin{formula}[Energía Potencial Elástica]
\begin{equation}
E_p = \frac{1}{2}kx^2
\end{equation}
Donde $E_p$ es la energía potencial almacenada, $k$ la constante elástica del material, y $x$ la distancia de estiramiento.
\end{formula}
\begin{formula}[Energía Cinética de Proyectil]
\begin{equation}
E_c = \frac{1}{2}mv^2
\end{equation}
Donde $E_c$ es la energía cinética, $m$ la masa del proyectil (bola de acero), y $v$ la velocidad de salida.
\end{formula}
\textbf{Sistema de manivela de tensión:}
\begin{itemize}
\item \textbf{Ventaja mecánica}: Relación de transmisión >10:1
\item \textbf{Acumulación gradual}: Energía aplicada en múltiples rotaciones
\item \textbf{Seguridad}: Reducción de riesgo de retroceso
\item \textbf{Precisión}: Control exacto del grado de tensión
\end{itemize}
\section{Acústica y Bioacústica}
\subsection{Propagación del Sonido en Diferentes Medios}
\begin{definicion}[Atenuación Sonora]
Pérdida de intensidad del sonido a medida que se propaga through un medio, dependiente de las propiedades físicas del medio y la frecuencia de la onda sonora.
\end{definicion}
\textbf{Comparación de alcances máximos:}
\begin{table}[h]
\centering
\begin{tabular}{@{}lcccc@{}}
\toprule
\textbf{Medio} & \textbf{Densidad} & \textbf{Animal} & \textbf{Frecuencia} & \textbf{Alcance} \\ \midrule
Aire & $1.225\text{ kg/m}^3$ & Lobo & 300-2000 Hz & 16 km \\
Agua & $1000\text{ kg/m}^3$ & Ballena azul & 10-40 Hz & 1600+ km \\
\bottomrule
\end{tabular}
\end{table}
\begin{importante}[Explicación Física]
\begin{itemize}
\item \textbf{Densidad del medio}: Agua ~800 veces más densa que aire
\item \textbf{Absorción}: Menor en agua para frecuencias bajas
\item \textbf{Canal SOFAR}: Canal de sonido profundo en océano que guía ondas sonoras
\item \textbf{Infrasonidos}: Frecuencias <20 Hz viajan miles de kilómetros con atenuación mínima
\end{itemize}
\end{importante}
\textbf{Análisis comparativo de atenuación:}
\begin{formula}[Coeficiente de Atenuación]
\begin{equation}
\alpha \propto \frac{f^2}{\rho c^3}
\end{equation}
Donde $\alpha$ es el coeficiente de atenuación, $f$ la frecuencia, $\rho$ la densidad del medio, y $c$ la velocidad del sonido. Menor atenuación en agua explica el alcance de 100$\times$ mayor para comunicaciones de ballenas.
\end{formula}
\subsection{Comunicación de Ballenas: Infrasonidos Oceánicos}
\begin{dato}[Ballena Azul]
Emite infrasonidos profundos (10-40 Hz) que pueden recorrer más de 1600 kilómetros bajo el agua, permitiendo comunicación entre individuos en cuencas oceánicas completas.
\end{dato}
\textbf{Adaptaciones evolutivas para comunicación de largo alcance:}
\begin{itemize}
\item \textbf{Frecuencias bajas}: Menor atenuación en agua
\item \textbf{Alta intensidad}: Hasta 188 dB (referenciado a 1 $\mu$Pa a 1 m)
\item \textbf{Repetición}: Patrones repetitivos para aumentar probabilidad de detección
\item \textbf{Canal SOFAR}: Aprovechamiento de canal acústico natural
\end{itemize}
\section{Eventos Culturales y Fenómenos Sociales}
\subsection{Festival de Lanzamiento de Vehículos: Glacier View, Alaska}
\begin{definicion}[Festival de Lanzamiento de Vehículos]
Evento anual en Glacier View, Alaska, donde vehículos son lanzados desde un acantilado de 90 metros hacia un estanque como forma de entretenimiento comunitario y expresión cultural.
\end{definicion}
\textbf{Especificaciones del evento:}
\begin{table}[h]
\centering
\begin{tabular}{@{}ll@{}}
\toprule
\textbf{Parámetro} & \textbf{Valor} \\ \midrule
Ubicación & Glacier View, Alaska ($62^\circ$N $145^\circ$W) \\
Altura del acantilado & 90 metros (295 pies) \\
Elevación del estanque & ~400 msnm \\
Temporada & Verano (junio-agosto) \\
Asistentes & 200-500 espectadores \\
Vehículos por evento & 10-30 unidades \\ \bottomrule
\end{tabular}
\end{table}
\begin{formula}[Tiempo de Caída Libre]
\begin{equation}
t = \sqrt{\frac{2h}{g}} = \sqrt{\frac{2 \times 90}{9.81}} \approx 4.28 \text{ segundos}
\end{equation}
\begin{equation}
v_{impacto} = gt = 9.81 \times 4.28 \approx 42 \text{ m/s} \approx 150 \text{ km/h}
\end{equation}
\end{formula}
\textbf{Aspectos sociológicos:}
\begin{itemize}
\item \textbf{Identidad comunitaria}: Evento distintivo que define a la comunidad
\item \textbf{Relación con tecnología}: Celebración irónica de cultura automotriz
\item \textbf{Arte efímero}: Decoración de vehículos como expresión artística temporal
\item \textbf{Economía local}: Atracción turística que genera ingresos para comunidad pequeña
\end{itemize}
\subsection{Gastronomía Espectáculo: Restaurante con Tirolesa}
\textbf{Sistema de entrega por tirolesa en Bangkok:}
\begin{itemize}
\item \textbf{Mecanismo}: Sistema de cables con gravedad asistida
\item \textbf{Seguridad}: Arnés de triple punto de anclaje
\item \textbf{Presentación}: Platos en contenedores especiales anti-volteo
\item \textbf{Experiencia}: Cada orden es performance en sí misma
\end{itemize}
\begin{ejemplo}[Diferenciación Experiencial]
En mercado restauratero saturado de Bangkok, la tirolesa no es solo método de entrega sino \textit{producto principal}: los clientes pagan tanto por el espectáculo de recibir su comida ``volando'' como por la comida misma.
\end{ejemplo}
\section{Arte y Expresión Creativa}
\subsection{Vodan Valsikov: Arquitectura Capilar}
\begin{dato}[Vodan Valsikov]
Barbero ucraniano viralizado por crear patrones geométricos complejos e ilusiones ópticas mediante corte de cabello, transformando la cabeza en lienzo artístico.
\end{dato}
\textbf{Técnicas empleadas:}
\begin{itemize}
\item \textbf{Geometría euclidiana}: Patrones basados en figuras regulares
\item \textbf{Perspectiva}: Uso de profundidad para crear ilusiones 3D
\item \textbf{Gradiente}: Variaciones de longitud para efecto de sombreado
\item \textbf{Simetría}: Patrones bilateral y radialmente simétricos
\end{itemize}
\subsection{Ghost Pittur: Arte Inverso Urbano}
\begin{definicion}[Arte Inverso / Reverse Graffiti]
Práctica artística que consiste en crear limpiando superficies sucias en lugar de aplicar pigmento; el ``arte'' emerge por sustracción de suciedad en lugar de por adición de material.
\end{definicion}
\textbf{Metodología de Ghost Pittur:}
\begin{enumerate}
\item \textbf{Reconocimiento}: Identificación de superficies vandalizadas
\item \textbf{Planificación}: Diseño del patrón de limpieza
\item \textbf{Ejecución}: Limpieza selectiva (generalmente con lavadora a presión)
\item \textbf{Revelación}: El patrón emerge por contraste entre superficie limpia y sucia
\item \textbf{Temporalidad}: Obra efímera que eventualmente será vandalizada nuevamente
\end{enumerate}
\textbf{Aspectos legales y filosóficos:}
\begin{itemize}
\item \textbf{Ambigüedad legal}: ¿Es vandalismo revertir vandalismo?
\item \textbf{Consentimiento}: Generalmente realizado sin permiso del propietario
\item \textbf{Valor estético}: ¿Es arte si no hay adición sino sustracción?
\item \textbf{Impermanencia}: Aceptación de la naturaleza temporal del trabajo
\end{itemize}
\subsection{Wang Liang: El Hombre Invisible}
\begin{dato}[Camuflaje Humano]
Artista chino especializado en pintura corporal que le permite fundirse completamente con entornos naturales y urbanos, documentando el performance mediante fotografía.
\end{dato}
\textbf{Proceso de creación:}
\begin{enumerate}
\item \textbf{Selección del entorno}: Identificación de fondo con potencial para camuflaje
\item \textbf{Documentación del fondo}: Fotografía de alta resolución del entorno
\item \textbf{Preparación de modelo}: Aplicación de pintura corporal base
\item \textbf{Pintura detallada}: Reproducción meticulosa de patrones del entorno
\item \textbf{Posicionamiento}: Alineación precisa del modelo con el fondo
\item \textbf{Documentación final}: Fotografía del performance terminado
\end{enumerate}
\textbf{Temas explorados:}
\begin{itemize}
\item \textbf{Identidad}: Disolución del yo en el entorno
\item \textbf{Observación}: Límites entre percepción y realidad
\item \textbf{Naturaleza vs. Cultura}: Integración de humano con entorno natural
\item \textbf{Anonimato}: Desaparición de individualidad en espacios masivos
\end{itemize}
\section{Cultura Digital y Fenómenos de Redes Sociales}
\subsection{Récord de YouTube: Avril Lavigne}
\begin{dato}[Primer Video en 100 Millones]
\textit{"Girlfriend"} de Avril Lavigne fue el primer video en YouTube en alcanzar 100 millones de reproducciones, marcando un hito en la era del contenido viral y estableciendo nuevas métricas de éxito en música digital.
\end{dato}
\textbf{Contexto histórico:}
\begin{itemize}
\item \textbf{Año}: 2007 (época temprana de YouTube)
\item \textbf{Plataforma}: YouTube fundado en 2005, en fase de expansión
\item \textbf{Industria musical}: Transición de MTV a YouTube como plataforma principal
\item \textbf{Métricas}: Establecimiento de vistas como medida de éxito
\end{itemize}
\subsection{Nombres Tecnológicos: ChatGPT Bastidas Guerra}
\begin{importante}[Caso ChatGPT]
Bebé registrada oficialmente en Cereté, Córdoba, Colombia, con el nombre ``ChatGPT Bastidas Guerra'', reflejando la penetración cultural de sistemas de inteligencia artificial en prácticas de nominación (naming practices).
\end{importante}
\textbf{Implicaciones sociológicas:}
\begin{enumerate}
\item \textbf{Digitalización de identidad}: Sistema de IA integrado a documento legal de identidad
\item \textbf{Memoria cultural}: Nombre preserva momento específico de adopción tecnológica
\item \textbf{Identidad anticipada}: Niña cargará marca comercial específica como nombre personal
\item \textbf{Potencial estigma}: Riesgo de asociación con tecnología que puede volverse obsoleta o controversial
\end{enumerate}
\textbf{Contexto legal:}
\begin{itemize}
\item \textbf{Ley colombiana}: Nombres deben respetar dignidad del niño
\item \textbf{Interpretación}: ¿Es ``ChatGPT'' un nombre válido bajo estándares de dignidad?
\item \textbf{Precedente}: Casos anteriores de nombres no tradicionales en Colombia
\end{itemize}
\subsection{Memoria Animal: Ariel la Ninfa}
\begin{definicion}[Memoria Auditiva Animal]
Capacidad cognitiva de procesar, almacenar y reproducir patrones sonoros complejos, documentada en diversas especies aviares y mamíferas.
\end{definicion}
\textbf{Caso Ariel -- Ninfa memoriza tono de iPhone:}
\begin{itemize}
\item \textbf{Especie}: Probablemente \textit{Agapornis} (lovebird) o similar
\item \textbf{Habilidad}: Memorización y reproducción de melodía específica
\item \textbf{Mecanismo}: Aprendizaje vocal por imitación
\item \textbf{Significado}: Demostración de memoria auditiva episódica en aves
\end{itemize}
\section{Nomenclatura y Coincidencias}
\subsection{HTX: Dualidad de Denominación}
\textbf{Coincidencia onomástica:}
\begin{itemize}
\item \textbf{HTX Studio}: Equipo de ingeniería con sede en China, desarrollador de cubos de basura inteligentes
\item \textbf{Comunidad HTX}: Referencia a comunidad de Glacier View, Alaska
\item \textbf{Similitud}: Ambas entidades utilizan sigla ``HTX''
\item \textbf{Diferencia}: No existe relación conocida entre las entidades
\end{itemize}
Este caso ilustra fenómeno de \textbf{convergencia onomástica} --diferentes entidades desarrollando independientemente nombres idénticos o similares--, relativamente común en contexto de globalización y abbreviated naming conventions.
\section{Análisis Demográfico}
\subsection{Distribución de Fechas de Nacimiento}
\begin{dato}[Patrones Estacionales]
Existen variaciones estadísticamente significativas en la frecuencia de nacimientos a lo largo del año, con ciertas fechas siendo considerablemente más comunes o raras que otras.
\end{dato}
\textbf{Factores que influyen en distribución:}
\begin{table}[h]
\centering
\begin{tabular}{@{}ll@{}}
\toprule
\textbf{Factor} & \textbf{Efecto} \\ \midrule
Concepción estacional & Picos en nacimientos 9 meses después \\
Condiciones climáticas & Menos concepciones en temperaturas extremas \\
Planificación cultural & Evitación de fechas festivas \\
Intervención médica & Cesáreas programadas en horarios laborales \\
Festividades & Aumento de concepciones durante vacaciones \\ \bottomrule
\end{tabular}
\end{table}
\textbf{Fechas estadísticamente extremas (EE.UU.):}
\begin{itemize}
\item \textbf{Más común}: Septiembre 16 (concepción navideña)
\item \textbf{Más rara}: Diciembre 25 (evitación de partos en Navidad)
\item \textbf{Segunda más común}: Septiembre 9
\item \textbf{Segunda más rara}: Enero 1 (evitación de Año Nuevo)
\end{itemize}
\begin{center}
\begin{tikzpicture}
\begin{axis}[
width=12cm,
height=4cm,
xlabel={Mes},
ylabel={Nacimientos (relativo a media)},
xtick={1,2,3,4,5,6,7,8,9,10,11,12},
xticklabels={Ene,Feb,Mar,Abr,May,Jun,Jul,Ago,Sep,Oct,Nov,Dic},
ymin=0.8, ymax=1.2,
grid=major,
]
\addplot[smooth, blue, thick] coordinates {
(1, 0.95) (2, 0.92) (3, 0.98) (4, 0.99) (5, 1.01) (6, 1.02)
(7, 1.03) (8, 1.05) (9, 1.12) (10, 1.08) (11, 1.02) (12, 0.85)
};
\end{axis}
\end{tikzpicture}
\end{center}
\section{Síntesis y Conclusiones}
\subsection{Temas Transversales Identificados}
La compilación de estas veinticinco curiosidades revela varios hilos conductores que conectan fenómenos aparentemente dispares:
\textbf{1. Adaptación y Evolución}
\begin{itemize}
\item Adaptación biológica: camuflaje del pez murciélago
\item Adaptación cultural: hot dog francés como variación local de producto global
\item Adaptación tecnológica: sistemas que aprenden y responden (cubos de basura inteligentes)
\end{itemize}
\textbf{2. Percepción y Realidad}
\begin{itemize}
\item Psicología: efecto señuelo manipula percepción de valor
\item Arte: Wang Liang y Ghost Pittur juegan con límites de percepción visual
\item Tecnología: realidad aumentada modifica percepción del entorno
\end{itemize}
\textbf{3. Optimización de Recursos}
\begin{itemize}
\item Biológica: pez murciélago ahorra energía mediante camuflaje pasivo
\item Industrial: Lego optimiza tolerancias para máxima compatibilidad
\item Económica: empresas diseñan arquitecturas de choice para maximizar ganancias
\end{itemize}
\textbf{4. Expresión y Creatividad}
\begin{itemize}
\item Gastronómica: innovaciones como hot dog francés o ketchup smoothie
\item Artística: Vodan Valsikov, Ghost Pittur, Wang Liang
\item Tecnológica: diseño de productos que incorporan creatividad (carritos incolisionables)
\end{itemize}
\subsection{Valor del Conocimiento Interconectado}
Este ejercicio de compilación demuestra que:
\begin{enumerate}
\item \textbf{Curiosidad como motor de aprendizaje}: Puntos de entrada triviales pueden llevar a comprensión profunda de conceptos complejos
\item \textbf{Interdisciplinariedad}: Fenómenos en un área (biología) pueden iluminar understanding en otra (ingeniería)
\item \textbf{Contextualización}: Datos aislados adquieren significado cuando conectados con patrones más amplios
\item \textbf{Serendipia}: Encuentros fortuitos entre conceptos aparentemente no relacionados generan nuevas insights
\end{enumerate}
\begin{importante}[Conclusión Principal]
Las ``cosas que no sabías hace cinco minutos'' --aunque aparentemente triviales-- funcionan como \textit{portales de entrada} a understanding más profundo de principios científicos, fenómenos culturales y patrones de comportamiento. Cada curiosidad es nodo en red de conocimiento, con conexiones que se extienden en múltiples direcciones hacia áreas tradicionalmente separadas del saber humano.
\end{importante}
\section*{Glosario}
\begin{description}[style=multiline, leftmargin=3.5cm, font=\bfseries]
\item[ABS] \textit{Acrylonitrile Butadiene Styrene}. Termoplástico utilizado en piezas Lego por su resistencia, durabilidad y precisión de moldeo.
\item[Atenuación sonora] Pérdida de intensidad de una onda sonora a medida que se propaga a través de un medio, dependiente de frecuencia y propiedades del medio.
\item[Açaí] Fruta de la palmera \textit{Euterpe oleracea}, nativa de región amazónica, rica en antioxidantes y antocianinas.
\item[Biofilm] Comunidad microbiana adherida a superficie, encapsulada en matriz extracelular polimérica; en contexto dental, ``placa bacteriana''.
\item[Canal SOFAR] \textit{Sound Fixing and Ranging Channel}. Capa oceánica donde la velocidad del sonido alcanza mínimo, creando guía de ondas naturales para comunicación de larga distancia.
\item[Camuflaje] Adaptación que permite organismo mezclarse visualmente con su entorno, evitando detección por depredadores o presas.
\item[Choice architecture] Diseño del entorno en que las personas toman decisiones, para influir en elección sin restringir opciones (concepto de Thaler y Sunstein).
\item[Decoy effect] Ver ``Efecto señuelo''.
\item[Definición operacional] Definición de concepto en términos de procedimientos o mediciones específicas, permitiendo su replicación experimental.
\item[Dióxido de titanio] Pigmento blanco ($TiO_2$) utilizado en plásticos; actúa como filler reforzante aumentando resistencia mecánica.
\item[Efecto señuelo] Sesgo cognitivo donde una opción poco atractiva (señuelo) modifica preferencias entre dos opciones principales.
\item[Emmental] Queso suizo de origen, caracterizado por agujeres formados por bubbles de $CO_2$ durante fermentación.
\item[Encapsulamiento] En programación orientada a objetos, ocultación de detalles de implementación exponiendo solo interfaz pública.
\item[Episodio memorial] Memoria de evento específico contextualizado en tiempo y espacio, con detalle fenomenológico.
\item[Frame] En teoría de decisiones, marco conceptual que influencia cómo opciones son percibidas y evaluadas.
\item[Glocalización] Estrategia de adaptar productos globales a preferencias locales, manteniendo elementos de identidad global.
\item[Gruyère] Queso suizo similar a emmental, utilizado en cocina francesa por sus características de fusión.
\item[Heuristic] Atajo mental simplificador que reduce carga cognitiva en toma de decisiones.
\item[HTX Studio] Equipo de ingeniería y tecnología con base en China, desarrollador de sistemas de cubos de basura inteligentes.
\item[Infrasonido] Sonido de frecuencia inferior a 20 Hz, por debajo del umbral auditivo humano pero detectable por algunos animales.
\item[Mechanics] Rama de física que estudia movimiento y fuerzas; en ingeniería, aplicación de principios mecánicos a diseño de sistemas.
\item[Mimetismo] Capacidad de organismo para imitar apariencia de otro objeto u organismo, obteniendo ventaja evolutiva.
\item[Machine Learning] Subcampo de inteligencia artificial enfocado en desarrollo de algoritmos que aprenden patrones a partir de datos.
\item[Neural network] Arquitectura computacional inspirada en redes neuronales biológicas, utilizada para reconocimiento de patrones.
\item[Ontogenia] Desarrollo de organismo individual desde fertilización hasta madurez.
\item[Placa dental] Biofilm adherido a superficie dental, compuesto por bacterias (principalmente \textit{Streptococcus mutans}), restos alimenticios y polímeros bacterianos.
\item[Repulsión magnética] Fuerza entre polos magnéticos iguales que causa alejamiento mutuo, descrita por ley de Coulomb magnética.
\item[Reverse graffiti] Ver ``Arte inverso''.
\item[Serendipia] Hallazgo fortuito o afortunado de algo valioso no buscado originalmente.
\item[Streptococcus mutans] Bacteria grampositiva, anaerobia facultativa, principal agente etiológico de caries dental en humanos.
\item[Sulforafano] Compuesto organosulfurado presente en vegetales crucíferos (brócoli, coliflor), con propiedades antibacterianas y antioxidantes.
\item[Tirolesa] Sistema de transporte consistente en cable tendido entre dos puntos, por el cual se desplaza persona o vehículo suspendido.
\item[Xpeng] Fabricante chino de vehículos eléctricos, conocido por integrar tecnología avanzada de realidad aumentada en automóviles.
\end{description}
\section*{Referencias}
\noindent La información presentada en este documento ha sido compilada de diversas fuentes incluyendo documentación científica, reportajes de medios, y observaciones de fenómenos culturales contemporáneos. Los datos específicos sobre resistencia de piezas Lego, características del efecto señuelo, y propiedades del brócoli están respaldados por literatura científica y técnica en las respectivas áreas.
\noindent Para mayor profundización en los temas presentados, se recomienda consultar:
\begin{itemize}
\item Literatura especializada en ciencia de materiales para análisis de pigmentos en polímeros
\item Investigaciones en economía conductual para estudio del efecto señuelo
\item Textos de bioacústica para comunicación de cetáceos
\item Documentación etológica para mimetismo en peces y otros organismos
\item Fuentes de sociología y antropología para análisis de fenómenos culturales contemporáneos
\end{itemize}
\end{document}

View File

@@ -28,6 +28,11 @@ class AIProvider(ABC):
"""Generate text from prompt""" """Generate text from prompt"""
pass pass
@abstractmethod
def fix_latex(self, latex_code: str, error_log: str, **kwargs) -> str:
"""Fix broken LaTeX code based on compiler error log"""
pass
@abstractmethod @abstractmethod
def is_available(self) -> bool: def is_available(self) -> bool:
"""Check if provider is available and configured""" """Check if provider is available and configured"""

View File

@@ -1,6 +1,7 @@
""" """
Claude AI Provider implementation Claude AI Provider implementation
""" """
import logging import logging
import subprocess import subprocess
import shutil import shutil
@@ -30,20 +31,35 @@ class ClaudeProvider(AIProvider):
def _get_env(self) -> Dict[str, str]: def _get_env(self) -> Dict[str, str]:
"""Get environment variables for Claude""" """Get environment variables for Claude"""
env = { # Load all user environment variables first
'ANTHROPIC_AUTH_TOKEN': self._token, import os
'ANTHROPIC_BASE_URL': self._base_url,
'PYTHONUNBUFFERED': '1' env = os.environ.copy()
}
# Override with our specific settings if available
if self._token:
env["ANTHROPIC_AUTH_TOKEN"] = self._token
if self._base_url:
env["ANTHROPIC_BASE_URL"] = self._base_url
# Add critical flags
env["PYTHONUNBUFFERED"] = "1"
# Ensure model variables are picked up from env (already in os.environ)
# but if we had explicit settings for them, we'd set them here.
# Since we put them in .env and loaded via load_dotenv -> os.environ,
# simply copying os.environ is sufficient.
return env return env
def _run_cli(self, prompt: str, timeout: int = 300) -> str: def _run_cli(self, prompt: str, timeout: int = 600) -> str:
"""Run Claude CLI with prompt""" """Run Claude CLI with prompt using -p flag for stdin input"""
if not self.is_available(): if not self.is_available():
raise AIProcessingError("Claude CLI not available or not configured") raise AIProcessingError("Claude CLI not available or not configured")
try: try:
cmd = [self._cli_path] # Use -p flag to read prompt from stdin, --dangerously-skip-permissions for automation
cmd = [self._cli_path, "--dangerously-skip-permissions", "-p", "-"]
process = subprocess.run( process = subprocess.run(
cmd, cmd,
input=prompt, input=prompt,
@@ -51,7 +67,7 @@ class ClaudeProvider(AIProvider):
text=True, text=True,
capture_output=True, capture_output=True,
timeout=timeout, timeout=timeout,
shell=False shell=False,
) )
if process.returncode != 0: if process.returncode != 0:
@@ -84,7 +100,12 @@ Return only the corrected text, nothing else."""
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]: def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
"""Classify content using Claude""" """Classify content using Claude"""
categories = ["historia", "analisis_contable", "instituciones_gobierno", "otras_clases"] categories = [
"historia",
"analisis_contable",
"instituciones_gobierno",
"otras_clases",
]
prompt = f"""Classify the following text into one of these categories: prompt = f"""Classify the following text into one of these categories:
- historia - historia
@@ -101,12 +122,37 @@ Return only the category name, nothing else."""
if result not in categories: if result not in categories:
result = "otras_clases" result = "otras_clases"
return { return {"category": result, "confidence": 0.9, "provider": self.name}
"category": result,
"confidence": 0.9,
"provider": self.name
}
def generate_text(self, prompt: str, **kwargs) -> str: def generate_text(self, prompt: str, **kwargs) -> str:
"""Generate text using Claude""" """Generate text using Claude"""
return self._run_cli(prompt) return self._run_cli(prompt)
def fix_latex(self, latex_code: str, error_log: str, **kwargs) -> str:
"""Fix broken LaTeX code using Claude"""
prompt = f"""I have a LaTeX file that failed to compile. Please fix the code.
COMPILER ERROR LOG:
{error_log[-3000:]}
BROKEN LATEX CODE:
{latex_code}
INSTRUCTIONS:
1. Analyze the error log to find the specific syntax error.
2. Fix the LaTeX code.
3. Return ONLY the full corrected LaTeX code.
4. Do not include markdown blocks or explanations.
5. Start immediately with \\documentclass.
COMMON LATEX ERRORS TO CHECK:
- TikZ nodes with line breaks (\\\\) MUST have "align=center" in their style.
WRONG: \\node[box] (n) {{Text\\\\More}};
CORRECT: \\node[box, align=center] (n) {{Text\\\\More}};
- All \\begin{{env}} must have matching \\end{{env}}
- All braces {{ }} must be balanced
- Math mode $ must be paired
- Special characters need escaping: % & # _
- tcolorbox environments need proper titles: [Title] not {{Title}}
"""
return self._run_cli(prompt, timeout=180)

View File

@@ -1,6 +1,7 @@
""" """
Gemini AI Provider - Optimized version with rate limiting and retry Gemini AI Provider - Optimized version with rate limiting and retry
""" """
import logging import logging
import subprocess import subprocess
import shutil import shutil
@@ -16,31 +17,32 @@ from .base_provider import AIProvider
class TokenBucket: class TokenBucket:
"""Token bucket rate limiter""" """Token bucket rate limiter"""
def __init__(self, rate: float = 10, capacity: int = 20): def __init__(self, rate: float = 10, capacity: int = 20):
self.rate = rate # tokens per second self.rate = rate # tokens per second
self.capacity = capacity self.capacity = capacity
self.tokens = capacity self.tokens = capacity
self.last_update = time.time() self.last_update = time.time()
self._lock = None # Lazy initialization self._lock = None # Lazy initialization
def _get_lock(self): def _get_lock(self):
if self._lock is None: if self._lock is None:
import threading import threading
self._lock = threading.Lock() self._lock = threading.Lock()
return self._lock return self._lock
def acquire(self, tokens: int = 1) -> float: def acquire(self, tokens: int = 1) -> float:
with self._get_lock(): with self._get_lock():
now = time.time() now = time.time()
elapsed = now - self.last_update elapsed = now - self.last_update
self.last_update = now self.last_update = now
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate) self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
if self.tokens >= tokens: if self.tokens >= tokens:
self.tokens -= tokens self.tokens -= tokens
return 0.0 return 0.0
wait_time = (tokens - self.tokens) / self.rate wait_time = (tokens - self.tokens) / self.rate
self.tokens = 0 self.tokens = 0
return wait_time return wait_time
@@ -48,7 +50,7 @@ class TokenBucket:
class CircuitBreaker: class CircuitBreaker:
"""Circuit breaker for API calls""" """Circuit breaker for API calls"""
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60): def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
self.failure_threshold = failure_threshold self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout self.recovery_timeout = recovery_timeout
@@ -56,21 +58,26 @@ class CircuitBreaker:
self.last_failure: Optional[datetime] = None self.last_failure: Optional[datetime] = None
self.state = "closed" # closed, open, half-open self.state = "closed" # closed, open, half-open
self._lock = None self._lock = None
def _get_lock(self): def _get_lock(self):
if self._lock is None: if self._lock is None:
import threading import threading
self._lock = threading.Lock() self._lock = threading.Lock()
return self._lock return self._lock
def call(self, func, *args, **kwargs): def call(self, func, *args, **kwargs):
with self._get_lock(): with self._get_lock():
if self.state == "open": if self.state == "open":
if self.last_failure and (datetime.utcnow() - self.last_failure).total_seconds() > self.recovery_timeout: if (
self.last_failure
and (datetime.utcnow() - self.last_failure).total_seconds()
> self.recovery_timeout
):
self.state = "half-open" self.state = "half-open"
else: else:
raise AIProcessingError("Circuit breaker is open") raise AIProcessingError("Circuit breaker is open")
try: try:
result = func(*args, **kwargs) result = func(*args, **kwargs)
if self.state == "half-open": if self.state == "half-open":
@@ -87,7 +94,7 @@ class CircuitBreaker:
class GeminiProvider(AIProvider): class GeminiProvider(AIProvider):
"""Gemini AI provider with rate limiting and retry""" """Gemini AI provider with rate limiting and retry"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@@ -102,17 +109,17 @@ class GeminiProvider(AIProvider):
"max_attempts": 3, "max_attempts": 3,
"base_delay": 1.0, "base_delay": 1.0,
"max_delay": 30.0, "max_delay": 30.0,
"exponential_base": 2 "exponential_base": 2,
} }
@property @property
def name(self) -> str: def name(self) -> str:
return "Gemini" return "Gemini"
def is_available(self) -> bool: def is_available(self) -> bool:
"""Check if Gemini CLI or API is available""" """Check if Gemini CLI or API is available"""
return bool(self._cli_path or self._api_key) return bool(self._cli_path or self._api_key)
def _init_session(self) -> None: def _init_session(self) -> None:
"""Initialize HTTP session with connection pooling""" """Initialize HTTP session with connection pooling"""
if self._session is None: if self._session is None:
@@ -120,17 +127,17 @@ class GeminiProvider(AIProvider):
adapter = requests.adapters.HTTPAdapter( adapter = requests.adapters.HTTPAdapter(
pool_connections=10, pool_connections=10,
pool_maxsize=20, pool_maxsize=20,
max_retries=0 # We handle retries manually max_retries=0, # We handle retries manually
) )
self._session.mount('https://', adapter) self._session.mount("https://", adapter)
def _run_with_retry(self, func, *args, **kwargs): def _run_with_retry(self, func, *args, **kwargs):
"""Execute function with exponential backoff retry""" """Execute function with exponential backoff retry"""
max_attempts = self._retry_config["max_attempts"] max_attempts = self._retry_config["max_attempts"]
base_delay = self._retry_config["base_delay"] base_delay = self._retry_config["base_delay"]
last_exception = None last_exception = None
for attempt in range(max_attempts): for attempt in range(max_attempts):
try: try:
return self._circuit_breaker.call(func, *args, **kwargs) return self._circuit_breaker.call(func, *args, **kwargs)
@@ -138,94 +145,84 @@ class GeminiProvider(AIProvider):
last_exception = e last_exception = e
if attempt < max_attempts - 1: if attempt < max_attempts - 1:
delay = min( delay = min(
base_delay * (2 ** attempt), base_delay * (2**attempt), self._retry_config["max_delay"]
self._retry_config["max_delay"]
) )
# Add jitter # Add jitter
delay += delay * 0.1 * (time.time() % 1) delay += delay * 0.1 * (time.time() % 1)
self.logger.warning(f"Attempt {attempt + 1} failed: {e}, retrying in {delay:.2f}s") self.logger.warning(
f"Attempt {attempt + 1} failed: {e}, retrying in {delay:.2f}s"
)
time.sleep(delay) time.sleep(delay)
raise AIProcessingError(f"Max retries exceeded: {last_exception}") raise AIProcessingError(f"Max retries exceeded: {last_exception}")
def _run_cli(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str: def _run_cli(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str:
"""Run Gemini CLI with prompt""" """Run Gemini CLI with prompt"""
if not self._cli_path: if not self._cli_path:
raise AIProcessingError("Gemini CLI not available") raise AIProcessingError("Gemini CLI not available")
model = self._flash_model if use_flash else self._pro_model model = self._flash_model if use_flash else self._pro_model
cmd = [self._cli_path, model, prompt] cmd = [self._cli_path, model, prompt]
try: try:
# Apply rate limiting # Apply rate limiting
wait_time = self._rate_limiter.acquire() wait_time = self._rate_limiter.acquire()
if wait_time > 0: if wait_time > 0:
time.sleep(wait_time) time.sleep(wait_time)
process = subprocess.run( process = subprocess.run(
cmd, cmd, text=True, capture_output=True, timeout=timeout, shell=False
text=True,
capture_output=True,
timeout=timeout,
shell=False
) )
if process.returncode != 0: if process.returncode != 0:
error_msg = process.stderr or "Unknown error" error_msg = process.stderr or "Unknown error"
raise AIProcessingError(f"Gemini CLI failed: {error_msg}") raise AIProcessingError(f"Gemini CLI failed: {error_msg}")
return process.stdout.strip() return process.stdout.strip()
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise AIProcessingError(f"Gemini CLI timed out after {timeout}s") raise AIProcessingError(f"Gemini CLI timed out after {timeout}s")
except Exception as e: except Exception as e:
raise AIProcessingError(f"Gemini CLI error: {e}") raise AIProcessingError(f"Gemini CLI error: {e}")
def _call_api(self, prompt: str, use_flash: bool = True, timeout: int = 180) -> str: def _call_api(self, prompt: str, use_flash: bool = True, timeout: int = 180) -> str:
"""Call Gemini API with rate limiting and retry""" """Call Gemini API with rate limiting and retry"""
if not self._api_key: if not self._api_key:
raise AIProcessingError("Gemini API key not configured") raise AIProcessingError("Gemini API key not configured")
self._init_session() self._init_session()
model = self._flash_model if use_flash else self._pro_model model = self._flash_model if use_flash else self._pro_model
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
payload = { payload = {"contents": [{"parts": [{"text": prompt}]}]}
"contents": [{
"parts": [{"text": prompt}]
}]
}
params = {"key": self._api_key} params = {"key": self._api_key}
def api_call(): def api_call():
# Apply rate limiting # Apply rate limiting
wait_time = self._rate_limiter.acquire() wait_time = self._rate_limiter.acquire()
if wait_time > 0: if wait_time > 0:
time.sleep(wait_time) time.sleep(wait_time)
response = self._session.post( response = self._session.post(
url, url, json=payload, params=params, timeout=timeout
json=payload,
params=params,
timeout=timeout
) )
response.raise_for_status() response.raise_for_status()
return response return response
response = self._run_with_retry(api_call) response = self._run_with_retry(api_call)
data = response.json() data = response.json()
if "candidates" not in data or not data["candidates"]: if "candidates" not in data or not data["candidates"]:
raise AIProcessingError("Empty response from Gemini API") raise AIProcessingError("Empty response from Gemini API")
candidate = data["candidates"][0] candidate = data["candidates"][0]
if "content" not in candidate or "parts" not in candidate["content"]: if "content" not in candidate or "parts" not in candidate["content"]:
raise AIProcessingError("Invalid response format from Gemini API") raise AIProcessingError("Invalid response format from Gemini API")
result = candidate["content"]["parts"][0]["text"] result = candidate["content"]["parts"][0]["text"]
return result.strip() return result.strip()
def _run(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str: def _run(self, prompt: str, use_flash: bool = True, timeout: int = 300) -> str:
"""Run Gemini with fallback between CLI and API""" """Run Gemini with fallback between CLI and API"""
# Try CLI first if available # Try CLI first if available
@@ -234,14 +231,14 @@ class GeminiProvider(AIProvider):
return self._run_cli(prompt, use_flash, timeout) return self._run_cli(prompt, use_flash, timeout)
except Exception as e: except Exception as e:
self.logger.warning(f"Gemini CLI failed, trying API: {e}") self.logger.warning(f"Gemini CLI failed, trying API: {e}")
# Fallback to API # Fallback to API
if self._api_key: if self._api_key:
api_timeout = min(timeout, 180) api_timeout = min(timeout, 180)
return self._call_api(prompt, use_flash, api_timeout) return self._call_api(prompt, use_flash, api_timeout)
raise AIProcessingError("No Gemini provider available (CLI or API)") raise AIProcessingError("No Gemini provider available (CLI or API)")
def summarize(self, text: str, **kwargs) -> str: def summarize(self, text: str, **kwargs) -> str:
"""Generate summary using Gemini""" """Generate summary using Gemini"""
prompt = f"""Summarize the following text: prompt = f"""Summarize the following text:
@@ -250,7 +247,7 @@ class GeminiProvider(AIProvider):
Provide a clear, concise summary in Spanish.""" Provide a clear, concise summary in Spanish."""
return self._run(prompt, use_flash=True) return self._run(prompt, use_flash=True)
def correct_text(self, text: str, **kwargs) -> str: def correct_text(self, text: str, **kwargs) -> str:
"""Correct text using Gemini""" """Correct text using Gemini"""
prompt = f"""Correct the following text for grammar, spelling, and clarity: prompt = f"""Correct the following text for grammar, spelling, and clarity:
@@ -259,11 +256,16 @@ Provide a clear, concise summary in Spanish."""
Return only the corrected text, nothing else.""" Return only the corrected text, nothing else."""
return self._run(prompt, use_flash=True) return self._run(prompt, use_flash=True)
def classify_content(self, text: str, **kwargs) -> Dict[str, Any]: def classify_content(self, text: str, **kwargs) -> Dict[str, Any]:
"""Classify content using Gemini""" """Classify content using Gemini"""
categories = ["historia", "analisis_contable", "instituciones_gobierno", "otras_clases"] categories = [
"historia",
"analisis_contable",
"instituciones_gobierno",
"otras_clases",
]
prompt = f"""Classify the following text into one of these categories: prompt = f"""Classify the following text into one of these categories:
- historia - historia
- analisis_contable - analisis_contable
@@ -274,39 +276,61 @@ Text: {text}
Return only the category name, nothing else.""" Return only the category name, nothing else."""
result = self._run(prompt, use_flash=True).lower() result = self._run(prompt, use_flash=True).lower()
# Validate result # Validate result
if result not in categories: if result not in categories:
result = "otras_clases" result = "otras_clases"
return { return {"category": result, "confidence": 0.9, "provider": self.name}
"category": result,
"confidence": 0.9,
"provider": self.name
}
def generate_text(self, prompt: str, **kwargs) -> str: def generate_text(self, prompt: str, **kwargs) -> str:
"""Generate text using Gemini""" """Generate text using Gemini"""
use_flash = kwargs.get('use_flash', True) use_flash = kwargs.get("use_flash", True)
if self._api_key: if self._api_key:
return self._call_api(prompt, use_flash=use_flash) return self._call_api(prompt, use_flash=use_flash)
return self._call_cli(prompt, use_yolo=True) return self._run_cli(prompt, use_flash=use_flash)
def fix_latex(self, latex_code: str, error_log: str, **kwargs) -> str:
"""Fix broken LaTeX code using Gemini"""
prompt = f"""Fix the following LaTeX code which failed to compile.
Error Log:
{error_log[-3000:]}
Broken Code:
{latex_code}
INSTRUCTIONS:
1. Return ONLY the corrected LaTeX code. No explanations.
2. Start immediately with \\documentclass.
COMMON LATEX ERRORS TO FIX:
- TikZ nodes with line breaks (\\\\) MUST have "align=center" in their style.
WRONG: \\node[box] (n) {{Text\\\\More}};
CORRECT: \\node[box, align=center] (n) {{Text\\\\More}};
- All \\begin{{env}} must have matching \\end{{env}}
- All braces {{ }} must be balanced
- Math mode $ must be paired
- Special characters need escaping: % & # _
- tcolorbox environments need proper titles: [Title] not {{Title}}
"""
return self._run(prompt, use_flash=False) # Use Pro model for coding fixes
def get_stats(self) -> Dict[str, Any]: def get_stats(self) -> Dict[str, Any]:
"""Get provider statistics""" """Get provider statistics"""
return { return {
"rate_limiter": { "rate_limiter": {
"tokens": round(self._rate_limiter.tokens, 2), "tokens": round(self._rate_limiter.tokens, 2),
"capacity": self._rate_limiter.capacity, "capacity": self._rate_limiter.capacity,
"rate": self._rate_limiter.rate "rate": self._rate_limiter.rate,
}, },
"circuit_breaker": { "circuit_breaker": {
"state": self._circuit_breaker.state, "state": self._circuit_breaker.state,
"failures": self._circuit_breaker.failures, "failures": self._circuit_breaker.failures,
"failure_threshold": self._circuit_breaker.failure_threshold "failure_threshold": self._circuit_breaker.failure_threshold,
}, },
"cli_available": bool(self._cli_path), "cli_available": bool(self._cli_path),
"api_available": bool(self._api_key) "api_available": bool(self._api_key),
} }

View File

@@ -0,0 +1,346 @@
"""
Parallel AI Provider - Race multiple providers for fastest response
Implements Strategy A: Parallel Generation with Consensus
"""
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Dict, List, Optional, Any
from datetime import datetime
from core import AIProcessingError
from .base_provider import AIProvider
@dataclass
class ProviderResult:
"""Result from a single provider"""
provider_name: str
content: str
duration_ms: int
success: bool
error: Optional[str] = None
quality_score: float = 0.0
@dataclass
class ParallelResult:
"""Aggregated result from parallel execution"""
content: str
strategy: str
providers_used: List[str]
total_duration_ms: int
all_results: List[ProviderResult]
selected_provider: str
class ParallelAIProvider:
"""
Orchestrates multiple AI providers in parallel for faster responses.
Strategies:
- "race": Use first successful response (fastest)
- "consensus": Wait for all, select best quality
- "majority": Select most common response
"""
def __init__(self, providers: Dict[str, AIProvider], max_workers: int = 4):
self.providers = providers
self.max_workers = max_workers
self.logger = logging.getLogger(__name__)
self.executor = ThreadPoolExecutor(max_workers=max_workers)
def _generate_sync(self, provider: AIProvider, prompt: str, **kwargs) -> ProviderResult:
"""Synchronous wrapper for provider generation"""
start_time = datetime.now()
try:
content = provider.generate_text(prompt, **kwargs)
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
# Calculate quality score
quality_score = self._calculate_quality_score(content)
return ProviderResult(
provider_name=provider.name,
content=content,
duration_ms=duration_ms,
success=True,
quality_score=quality_score
)
except Exception as e:
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
self.logger.error(f"{provider.name} failed: {e}")
return ProviderResult(
provider_name=provider.name,
content="",
duration_ms=duration_ms,
success=False,
error=str(e)
)
def _calculate_quality_score(self, content: str) -> float:
"""Calculate quality score for generated content"""
score = 0.0
# Length check (comprehensive is better)
if 500 < len(content) < 50000:
score += 0.2
# LaTeX structure validation
latex_indicators = [
r"\documentclass",
r"\begin{document}",
r"\section",
r"\subsection",
r"\begin{itemize}",
r"\end{document}"
]
found_indicators = sum(1 for ind in latex_indicators if ind in content)
score += (found_indicators / len(latex_indicators)) * 0.4
# Bracket matching
if content.count("{") == content.count("}"):
score += 0.2
# Environment closure
envs = ["document", "itemize", "enumerate"]
for env in envs:
if content.count(f"\\begin{{{env}}}") == content.count(f"\\end{{{env}}}"):
score += 0.1
# Has content beyond template
if len(content) > 1000:
score += 0.1
return min(score, 1.0)
def generate_parallel(
self,
prompt: str,
strategy: str = "race",
timeout_ms: int = 300000, # 5 minutes default
**kwargs
) -> ParallelResult:
"""
Execute prompt across multiple providers in parallel.
Args:
prompt: The prompt to send to all providers
strategy: "race", "consensus", or "majority"
timeout_ms: Maximum time to wait for results
**kwargs: Additional arguments for providers
Returns:
ParallelResult with selected content and metadata
"""
if not self.providers:
raise AIProcessingError("No providers available for parallel execution")
start_time = datetime.now()
all_results: List[ProviderResult] = []
# Submit all providers
futures = {}
for name, provider in self.providers.items():
if provider.is_available():
future = self.executor.submit(
self._generate_sync,
provider,
prompt,
**kwargs
)
futures[future] = name
# Wait for results based on strategy
if strategy == "race":
all_results = self._race_strategy(futures, timeout_ms)
elif strategy == "consensus":
all_results = self._consensus_strategy(futures, timeout_ms)
elif strategy == "majority":
all_results = self._majority_strategy(futures, timeout_ms)
else:
raise ValueError(f"Unknown strategy: {strategy}")
# Select best result
selected = self._select_result(all_results, strategy)
total_duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
self.logger.info(
f"Parallel generation complete: {strategy} strategy, "
f"{len(all_results)} providers, {selected.provider_name} selected, "
f"{total_duration_ms}ms"
)
return ParallelResult(
content=selected.content,
strategy=strategy,
providers_used=[r.provider_name for r in all_results if r.success],
total_duration_ms=total_duration_ms,
all_results=all_results,
selected_provider=selected.provider_name
)
def _race_strategy(
self,
futures: dict,
timeout_ms: int
) -> List[ProviderResult]:
"""Return first successful response"""
results = []
for future in as_completed(futures, timeout=timeout_ms / 1000):
try:
result = future.result()
results.append(result)
if result.success:
# Got a successful response, cancel remaining
for f in futures:
f.cancel()
break
except Exception as e:
self.logger.error(f"Future failed: {e}")
return results
def _consensus_strategy(
self,
futures: dict,
timeout_ms: int
) -> List[ProviderResult]:
"""Wait for all, return all results"""
results = []
for future in as_completed(futures, timeout=timeout_ms / 1000):
try:
result = future.result()
results.append(result)
except Exception as e:
self.logger.error(f"Future failed: {e}")
return results
def _majority_strategy(
self,
futures: dict,
timeout_ms: int
) -> List[ProviderResult]:
"""Wait for majority, select most common response"""
results = []
for future in as_completed(futures, timeout=timeout_ms / 1000):
try:
result = future.result()
results.append(result)
except Exception as e:
self.logger.error(f"Future failed: {e}")
return results
def _select_result(self, results: List[ProviderResult], strategy: str) -> ProviderResult:
"""Select best result based on strategy"""
successful = [r for r in results if r.success]
if not successful:
# Return first failed result with error info
return results[0] if results else ProviderResult(
provider_name="none",
content="",
duration_ms=0,
success=False,
error="All providers failed"
)
if strategy == "race" or len(successful) == 1:
return successful[0]
if strategy == "consensus":
# Select by quality score
return max(successful, key=lambda r: r.quality_score)
if strategy == "majority":
# Group by similar content (simplified - use longest)
return max(successful, key=lambda r: len(r.content))
return successful[0]
def fix_latex_parallel(
self,
latex_code: str,
error_log: str,
timeout_ms: int = 120000,
**kwargs
) -> ParallelResult:
"""Try to fix LaTeX across multiple providers in parallel"""
# Build fix prompt for each provider
results = []
start_time = datetime.now()
for name, provider in self.providers.items():
if provider.is_available():
try:
start = datetime.now()
fixed = provider.fix_latex(latex_code, error_log, **kwargs)
duration_ms = int((datetime.now() - start).total_seconds() * 1000)
# Score by checking if error patterns are reduced
quality = self._score_latex_fix(fixed, error_log)
results.append(ProviderResult(
provider_name=name,
content=fixed,
duration_ms=duration_ms,
success=True,
quality_score=quality
))
except Exception as e:
self.logger.error(f"{name} fix failed: {e}")
# Select best fix
if results:
selected = max(results, key=lambda r: r.quality_score)
total_duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
return ParallelResult(
content=selected.content,
strategy="consensus",
providers_used=[r.provider_name for r in results],
total_duration_ms=total_duration_ms,
all_results=results,
selected_provider=selected.provider_name
)
raise AIProcessingError("All providers failed to fix LaTeX")
def _score_latex_fix(self, fixed_latex: str, original_error: str) -> float:
"""Score a LaTeX fix attempt"""
score = 0.5 # Base score
# Check if common error patterns are addressed
error_patterns = [
("Undefined control sequence", r"\\[a-zA-Z]+"),
("Missing $ inserted", r"\$.*\$"),
("Runaway argument", r"\{.*\}"),
]
for error_msg, pattern in error_patterns:
if error_msg in original_error:
# If error was in original, check if pattern appears better
score += 0.1
# Validate bracket matching
if fixed_latex.count("{") == fixed_latex.count("}"):
score += 0.2
# Validate environment closure
envs = ["document", "itemize", "enumerate"]
for env in envs:
begin_count = fixed_latex.count(f"\\begin{{{env}}}")
end_count = fixed_latex.count(f"\\end{{{env}}}")
if begin_count == end_count:
score += 0.1
return min(score, 1.0)
def shutdown(self):
"""Shutdown the executor"""
self.executor.shutdown(wait=True)
def __del__(self):
self.shutdown()

View File

@@ -0,0 +1,343 @@
"""
Prompt Manager - Centralized prompt management using resumen.md as source of truth
"""
import re
import os
from pathlib import Path
from typing import Optional, Dict, Any
from config import settings
class PromptManager:
"""
Manages prompts for AI services, loading templates from latex/resumen.md
This is the SINGLE SOURCE OF TRUTH for academic summary generation.
"""
_instance = None
_prompt_cache: Optional[str] = None
_latex_preamble_cache: Optional[str] = None
# Path to the prompt template file
PROMPT_FILE_PATH = Path("latex/resumen.md")
def __new__(cls):
if cls._instance is None:
cls._instance = super(PromptManager, cls).__new__(cls)
return cls._instance
def _load_prompt_template(self) -> str:
"""Load the complete prompt template from resumen.md"""
if self._prompt_cache:
return self._prompt_cache
try:
file_path = self.PROMPT_FILE_PATH.resolve()
if not file_path.exists():
self._prompt_cache = self._get_fallback_prompt()
return self._prompt_cache
content = file_path.read_text(encoding="utf-8")
# The file has a markdown code block after "## Prompt Template"
# We need to find the content from "## Prompt Template" to the LAST ```
# (because there's a ```latex...``` block INSIDE the template)
# First, find where "## Prompt Template" starts
template_start = content.find("## Prompt Template")
if template_start == -1:
self._prompt_cache = self._get_fallback_prompt()
return self._prompt_cache
# Find the opening ``` after the header
after_header = content[template_start:]
code_block_start = after_header.find("```")
if code_block_start == -1:
self._prompt_cache = self._get_fallback_prompt()
return self._prompt_cache
# Skip the opening ``` and any language specifier
after_code_start = after_header[code_block_start + 3:]
first_newline = after_code_start.find("\n")
if first_newline != -1:
actual_content_start = template_start + code_block_start + 3 + first_newline + 1
else:
actual_content_start = template_start + code_block_start + 3
# Now find the LAST ``` that closes the main block
# We look for ``` followed by optional space and then newline or end
remaining = content[actual_content_start:]
# Find all positions of ``` in the remaining content
positions = []
pos = 0
while True:
found = remaining.find("```", pos)
if found == -1:
break
positions.append(found)
pos = found + 3
if not positions:
self._prompt_cache = self._get_fallback_prompt()
return self._prompt_cache
# The LAST ``` is the closing of the main block
# (all previous ``` are the latex block inside the template)
last_backtick_pos = positions[-1]
# Extract the content
template_content = content[actual_content_start:actual_content_start + last_backtick_pos]
# Remove leading newline if present
template_content = template_content.lstrip("\n")
self._prompt_cache = template_content
return self._prompt_cache
except Exception as e:
print(f"Error loading prompt file: {e}")
self._prompt_cache = self._get_fallback_prompt()
return self._prompt_cache
def _get_fallback_prompt(self) -> str:
"""Fallback prompt if resumen.md is not found"""
return """Sos un asistente académico experto. Creá un resumen extenso en LaTeX basado en la transcripción de clase.
## Transcripción de clase:
[PEGAR TRANSCRIPCIÓN AQUÍ]
## Material bibliográfico:
[PEGAR TEXTO DEL LIBRO/APUNTE O INDICAR QUE LO SUBISTE COMO ARCHIVO]
Generá un archivo LaTeX completo con:
- Estructura académica formal
- Mínimo 10 páginas de contenido
- Fórmulas matemáticas en LaTeX
- Tablas y diagramas cuando corresponda
"""
def _load_latex_preamble(self) -> str:
"""Extract the LaTeX preamble from resumen.md"""
if self._latex_preamble_cache:
return self._latex_preamble_cache
try:
file_path = self.PROMPT_FILE_PATH.resolve()
if not file_path.exists():
return self._get_default_preamble()
content = file_path.read_text(encoding="utf-8")
# Extract LaTeX code block in the template
match = re.search(
r"```latex\s*\n([\s\S]*?)\n```",
content
)
if match:
self._latex_preamble_cache = match.group(1).strip()
else:
self._latex_preamble_cache = self._get_default_preamble()
return self._latex_preamble_cache
except Exception as e:
print(f"Error loading LaTeX preamble: {e}")
return self._get_default_preamble()
def _get_default_preamble(self) -> str:
"""Default LaTeX preamble"""
return r"""\documentclass[11pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[spanish,provide=*]{babel}
\usepackage{amsmath,amssymb}
\usepackage{geometry}
\usepackage{graphicx}
\usepackage{tikz}
\usetikzlibrary{arrows.meta,positioning,shapes.geometric,calc}
\usepackage{booktabs}
\usepackage{enumitem}
\usepackage{fancyhdr}
\usepackage{titlesec}
\usepackage{tcolorbox}
\usepackage{array}
\usepackage{multirow}
\geometry{margin=2.5cm}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{[MATERIA] - CBC}
\fancyhead[R]{Clase [N]}
\fancyfoot[C]{\thepage}
% Cajas para destacar contenido
\newtcolorbox{definicion}[1][]{
colback=blue!5!white,
colframe=blue!75!black,
fonttitle=\bfseries,
title=#1
}
\newtcolorbox{importante}[1][]{
colback=red!5!white,
colframe=red!75!black,
fonttitle=\bfseries,
title=#1
}
\newtcolorbox{ejemplo}[1][]{
colback=green!5!white,
colframe=green!50!black,
fonttitle=\bfseries,
title=#1
}
"""
def get_latex_summary_prompt(
self,
transcription: str,
materia: str = "Economía",
bibliographic_text: Optional[str] = None,
class_number: Optional[int] = None
) -> str:
"""
Generate the complete prompt for LaTeX academic summary based on resumen.md template.
Args:
transcription: The class transcription text
materia: Subject name (default: "Economía")
bibliographic_text: Optional supporting text from books/notes
class_number: Optional class number for header
Returns:
Complete prompt string ready to send to AI
"""
template = self._load_prompt_template()
# CRITICAL: Prepend explicit instructions to force direct LaTeX generation
# (This doesn't modify resumen.md, just adds context before it)
explicit_instructions = """CRITICAL: Tu respuesta debe ser ÚNICAMENTE código LaTeX.
INSTRUCCIONES OBLIGATORIAS:
1. NO incluyas explicaciones previas
2. NO describas lo que vas a hacer
3. Comienza INMEDIATAMENTE con \\documentclass
4. Tu respuesta debe ser SOLO el código LaTeX fuente
5. Termina con \\end{document}
---
"""
prompt = explicit_instructions + template
# Replace placeholders
prompt = prompt.replace("[MATERIA]", materia)
# Insert transcription
if "[PEGAR TRANSCRIPCIÓN AQUÍ]" in prompt:
prompt = prompt.replace("[PEGAR TRANSCRIPCIÓN AQUÍ]", transcription)
else:
prompt += f"\n\n## Transcripción de clase:\n{transcription}"
# Insert bibliographic material
bib_text = bibliographic_text or "No se proporcionó material bibliográfico adicional."
if "[PEGAR TEXTO DEL LIBRO/APUNTE O INDICAR QUE LO SUBISTE COMO ARCHIVO]" in prompt:
prompt = prompt.replace(
"[PEGAR TEXTO DEL LIBRO/APUNTE O INDICAR QUE LO SUBISTE COMO ARCHIVO]",
bib_text
)
else:
prompt += f"\n\n## Material bibliográfico:\n{bib_text}"
# Add class number if provided
if class_number is not None:
prompt = prompt.replace("[N]", str(class_number))
return prompt
def get_latex_preamble(
self,
materia: str = "Economía",
class_number: Optional[int] = None
) -> str:
"""
Get the LaTeX preamble with placeholders replaced.
Args:
materia: Subject name
class_number: Optional class number
Returns:
Complete LaTeX preamble as string
"""
preamble = self._load_latex_preamble()
# Replace placeholders
preamble = preamble.replace("[MATERIA]", materia)
if class_number is not None:
preamble = preamble.replace("[N]", str(class_number))
return preamble
def get_latex_fix_prompt(self, latex_code: str, error_log: str) -> str:
"""Get prompt for fixing broken LaTeX code"""
return f"""I have a LaTeX file that failed to compile. Please fix the code.
COMPILER ERROR LOG:
{error_log[-3000:]}
BROKEN LATEX CODE:
{latex_code}
INSTRUCTIONS:
1. Analyze the error log to find the specific syntax error.
2. Fix the LaTeX code.
3. Return ONLY the full corrected LaTeX code.
4. Do not include markdown blocks or explanations.
5. Start immediately with \\documentclass.
6. Ensure all braces {{}} are properly balanced.
7. Ensure all environments \\begin{{...}} have matching \\end{{...}}.
8. Ensure all packages are properly declared.
"""
def extract_latex_from_response(self, response: str) -> Optional[str]:
"""
Extract clean LaTeX code from AI response.
Handles cases where AI wraps LaTeX in ```latex...``` blocks.
"""
if not response:
return None
# Try to find content inside ```latex ... ``` blocks
code_block_pattern = r"```(?:latex|tex)?\s*([\s\S]*?)\s*```"
match = re.search(code_block_pattern, response, re.IGNORECASE)
if match:
latex = match.group(1).strip()
else:
latex = response.strip()
# Verify it looks like LaTeX
if "\\documentclass" not in latex:
return None
# Clean up: remove anything before \documentclass
start_idx = latex.find("\\documentclass")
latex = latex[start_idx:]
# Clean up: remove anything after \end{document}
if "\\end{document}" in latex:
end_idx = latex.rfind("\\end{document}")
latex = latex[:end_idx + len("\\end{document}")]
return latex.strip()
# Singleton instance for easy import
prompt_manager = PromptManager()

View File

@@ -1,26 +1,29 @@
""" """
AI Provider Factory (Factory Pattern) AI Provider Factory (Factory Pattern)
""" """
import logging import logging
from typing import Dict, Type from typing import Dict, Type, Optional
from core import AIProcessingError from core import AIProcessingError
from .base_provider import AIProvider from .base_provider import AIProvider
from .claude_provider import ClaudeProvider from .claude_provider import ClaudeProvider
from .gemini_provider import GeminiProvider from .gemini_provider import GeminiProvider
from .parallel_provider import ParallelAIProvider
class AIProviderFactory: class AIProviderFactory:
"""Factory for creating AI providers with fallback""" """Factory for creating AI providers with fallback and parallel execution"""
def __init__(self): def __init__(self):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self._providers: Dict[str, AIProvider] = { self._providers: Dict[str, AIProvider] = {
'claude': ClaudeProvider(), "claude": ClaudeProvider(),
'gemini': GeminiProvider() "gemini": GeminiProvider(),
} }
self._parallel_provider: Optional[ParallelAIProvider] = None
def get_provider(self, preferred: str = 'gemini') -> AIProvider: def get_provider(self, preferred: str = "gemini") -> AIProvider:
"""Get available provider with fallback""" """Get available provider with fallback"""
# Try preferred provider first # Try preferred provider first
if preferred in self._providers: if preferred in self._providers:
@@ -46,8 +49,31 @@ class AIProviderFactory:
} }
def get_best_provider(self) -> AIProvider: def get_best_provider(self) -> AIProvider:
"""Get the best available provider (Gemini > Claude)""" """Get the best available provider (Claude > Gemini)"""
return self.get_provider('gemini') return self.get_provider("claude")
def get_parallel_provider(self, max_workers: int = 4) -> ParallelAIProvider:
"""Get parallel provider for racing multiple AI providers"""
available = self.get_all_available()
if not available:
raise AIProcessingError("No providers available for parallel execution")
if self._parallel_provider is None:
self._parallel_provider = ParallelAIProvider(
providers=available,
max_workers=max_workers
)
self.logger.info(
f"Created parallel provider with {len(available)} workers: "
f"{', '.join(available.keys())}"
)
return self._parallel_provider
def use_parallel(self) -> bool:
"""Check if parallel execution should be used (multiple providers available)"""
return len(self.get_all_available()) > 1
# Global instance # Global instance

353
services/notion_service.py Normal file
View File

@@ -0,0 +1,353 @@
"""
Notion integration service with official SDK
"""
import logging
from typing import Optional, Dict, Any, List
from pathlib import Path
from datetime import datetime
import time
try:
from notion_client import Client
from notion_client.errors import APIResponseError
NOTION_AVAILABLE = True
except ImportError:
NOTION_AVAILABLE = False
Client = None
APIResponseError = Exception
from config import settings
class NotionService:
"""Enhanced Notion API integration service"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self._client: Optional[Client] = None
self._database_id: Optional[str] = None
def configure(self, token: str, database_id: str) -> None:
"""Configure Notion with official SDK"""
if not NOTION_AVAILABLE:
self.logger.error(
"notion-client not installed. Install with: pip install notion-client"
)
return
self._client = Client(auth=token)
self._database_id = database_id
self.logger.info("Notion service configured with official SDK")
@property
def is_configured(self) -> bool:
"""Check if Notion is configured"""
return bool(self._client and self._database_id and NOTION_AVAILABLE)
def _rate_limited_request(self, func, *args, **kwargs):
"""Execute request with rate limiting and retry"""
max_retries = 3
base_delay = 1
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except APIResponseError as e:
if hasattr(e, "code") and e.code == "rate_limited":
delay = base_delay * (2**attempt)
self.logger.warning(f"Rate limited by Notion, waiting {delay}s")
time.sleep(delay)
else:
raise
raise Exception("Max retries exceeded for Notion API")
def create_page_with_summary(
self, title: str, summary: str, metadata: Dict[str, Any]
) -> Optional[str]:
"""Create a new page in Notion (database or parent page) with summary content"""
if not self.is_configured:
self.logger.warning("Notion not configured, skipping upload")
return None
try:
# Determinar si es database o página padre
use_as_page = metadata.get("use_as_page", False)
if use_as_page:
# Crear página dentro de otra página
page = self._rate_limited_request(
self._client.pages.create,
parent={"page_id": self._database_id},
properties={"title": [{"text": {"content": title[:100]}}]},
)
else:
# Crear página en database (método original)
properties = {"Name": {"title": [{"text": {"content": title[:100]}}]}}
# Agregar status si la DB lo soporta
if metadata.get("add_status", True):
properties["Status"] = {"select": {"name": "Procesado"}}
# Agregar tipo de archivo si está disponible Y add_status está habilitado
if metadata.get("add_status", False) and metadata.get("file_type"):
properties["Tipo"] = {
"select": {" name": metadata["file_type"].upper()}
}
page = self._rate_limited_request(
self._client.pages.create,
parent={"database_id": self._database_id},
properties=properties,
)
page_id = page["id"]
self.logger.info(f"✅ Notion page created: {page_id}")
# Agregar contenido del resumen como bloques
self._add_summary_content(page_id, summary, metadata.get("pdf_path"))
return page_id
except Exception as e:
self.logger.error(f"❌ Error creating Notion page: {e}")
return None
try:
# Preparar properties de la página
properties = {
"Name": {
"title": [
{
"text": {
"content": title[:100] # Notion limit
}
}
]
}
}
# Agregar status si la DB lo soporta
if metadata.get("add_status", True):
properties["Status"] = {"select": {"name": "Procesado"}}
# Agregar tipo de archivo si está disponible
if metadata.get("file_type"):
properties["Tipo"] = {"select": {"name": metadata["file_type"].upper()}}
# Crear página
page = self._rate_limited_request(
self._client.pages.create,
parent={"database_id": self._database_id},
properties=properties,
)
page_id = page["id"]
self.logger.info(f"✅ Notion page created: {page_id}")
# Agregar contenido del resumen como bloques
self._add_summary_content(page_id, summary, metadata.get("pdf_path"))
return page_id
except Exception as e:
self.logger.error(f"❌ Error creating Notion page: {e}")
return None
def _add_summary_content(
self, page_id: str, summary: str, pdf_path: Optional[Path] = None
) -> bool:
"""Add summary content as Notion blocks"""
try:
blocks = []
# Agregar nota sobre el PDF si existe
if pdf_path and pdf_path.exists():
blocks.append(
{
"object": "block",
"type": "callout",
"callout": {
"rich_text": [
{
"type": "text",
"text": {
"content": f"📄 Documento generado automáticamente: {pdf_path.name}"
},
}
],
"icon": {"emoji": "📄"},
},
}
)
# Agregar bloques del resumen
summary_blocks = self._parse_markdown_to_blocks(summary)
blocks.extend(summary_blocks)
# Agregar footer
blocks.append({"object": "block", "type": "divider", "divider": {}})
blocks.append(
{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [
{
"type": "text",
"text": {
"content": f"Generado por CBCFacil el {datetime.now().strftime('%d/%m/%Y %H:%M')}"
},
"annotations": {"italic": True, "color": "gray"},
}
]
},
}
)
# Notion API limita a 100 bloques por request
if blocks:
for i in range(0, len(blocks), 100):
batch = blocks[i : i + 100]
self._rate_limited_request(
self._client.blocks.children.append,
block_id=page_id,
children=batch,
)
self.logger.info(f"✅ Added {len(blocks)} blocks to Notion page")
return True
except Exception as e:
self.logger.error(f"❌ Error adding content blocks: {e}")
return False
def _parse_markdown_to_blocks(self, markdown: str) -> List[Dict]:
"""Convert markdown to Notion blocks"""
blocks = []
lines = markdown.split("\n")
for line in lines:
line = line.strip()
if not line:
continue
# Headings
if line.startswith("# "):
text = line[2:].strip()[:2000]
if text:
blocks.append(
{
"object": "block",
"type": "heading_1",
"heading_1": {
"rich_text": [
{"type": "text", "text": {"content": text}}
]
},
}
)
elif line.startswith("## "):
text = line[3:].strip()[:2000]
if text:
blocks.append(
{
"object": "block",
"type": "heading_2",
"heading_2": {
"rich_text": [
{"type": "text", "text": {"content": text}}
]
},
}
)
elif line.startswith("### "):
text = line[4:].strip()[:2000]
if text:
blocks.append(
{
"object": "block",
"type": "heading_3",
"heading_3": {
"rich_text": [
{"type": "text", "text": {"content": text}}
]
},
}
)
# Bullet points
elif line.startswith("- ") or line.startswith("* "):
text = line[2:].strip()[:2000]
if text:
blocks.append(
{
"object": "block",
"type": "bulleted_list_item",
"bulleted_list_item": {
"rich_text": [
{"type": "text", "text": {"content": text}}
]
},
}
)
# Divider
elif line.strip() == "---":
blocks.append({"object": "block", "type": "divider", "divider": {}})
# Paragraph (skip footer lines)
elif not line.startswith("*Generado por"):
text = line[:2000]
if text:
blocks.append(
{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [
{"type": "text", "text": {"content": text}}
]
},
}
)
return blocks
def upload_pdf_legacy(self, pdf_path: Path, title: str) -> bool:
"""Legacy method - creates simple page (backward compatibility)"""
if not self.is_configured:
self.logger.warning("Notion not configured, skipping upload")
return False
try:
# Crear página simple
page_id = self.create_page_with_summary(
title=title,
summary=f"Documento procesado: {title}",
metadata={"file_type": "PDF", "pdf_path": pdf_path},
)
return bool(page_id)
except Exception as e:
self.logger.error(f"Error uploading PDF to Notion: {e}")
return False
# Alias para backward compatibility
def upload_pdf(self, pdf_path: Path, title: str) -> bool:
"""Upload PDF info to Notion (alias for backward compatibility)"""
return self.upload_pdf_legacy(pdf_path, title)
def upload_pdf_as_file(self, pdf_path: Path, title: str) -> bool:
"""Upload PDF info as file (alias for backward compatibility)"""
return self.upload_pdf_legacy(pdf_path, title)
# Global instance
notion_service = NotionService()
def upload_to_notion(pdf_path: Path, title: str) -> bool:
"""Legacy function for backward compatibility"""
return notion_service.upload_pdf(pdf_path, title)

View File

@@ -0,0 +1,203 @@
"""
Notion integration service
"""
import logging
import base64
from typing import Optional
from pathlib import Path
try:
import requests
REQUESTS_AVAILABLE = True
except ImportError:
REQUESTS_AVAILABLE = False
requests = None
from config import settings
class NotionService:
"""Service for Notion API integration"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self._token: Optional[str] = None
self._database_id: Optional[str] = None
self._base_url = "https://api.notion.com/v1"
def configure(self, token: str, database_id: str) -> None:
"""Configure Notion credentials"""
self._token = token
self._database_id = database_id
self.logger.info("Notion service configured")
@property
def is_configured(self) -> bool:
"""Check if Notion is configured"""
return bool(self._token and self._database_id)
def _get_headers(self) -> dict:
"""Get headers for Notion API requests"""
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
"Notion-Version": "2022-06-28"
}
def upload_pdf(self, pdf_path: Path, title: str) -> bool:
"""Upload PDF to Notion database"""
if not self.is_configured:
self.logger.warning("Notion not configured, skipping upload")
return False
if not REQUESTS_AVAILABLE:
self.logger.error("requests library not available for Notion upload")
return False
if not pdf_path.exists():
self.logger.error(f"PDF file not found: {pdf_path}")
return False
try:
# Read and encode PDF
with open(pdf_path, 'rb') as f:
pdf_data = base64.b64encode(f.read()).decode('utf-8')
# Prepare the page data
page_data = {
"parent": {"database_id": self._database_id},
"properties": {
"Name": {
"title": [
{
"text": {
"content": title
}
}
]
},
"Status": {
"select": {
"name": "Procesado"
}
}
},
"children": [
{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [
{
"type": "text",
"text": {
"content": f"Documento generado automáticamente: {title}"
}
}
]
}
},
{
"object": "block",
"type": "file",
"file": {
"type": "external",
"external": {
"url": f"data:application/pdf;base64,{pdf_data}"
}
}
}
]
}
# Create page in database
response = requests.post(
f"{self._base_url}/pages",
headers=self._get_headers(),
json=page_data,
timeout=30
)
if response.status_code == 200:
self.logger.info(f"PDF uploaded to Notion successfully: {title}")
return True
else:
self.logger.error(f"Notion API error: {response.status_code} - {response.text}")
return False
except Exception as e:
self.logger.error(f"Error uploading PDF to Notion: {e}")
return False
def upload_pdf_as_file(self, pdf_path: Path, title: str) -> bool:
"""Upload PDF as a file block (alternative method)"""
if not self.is_configured:
self.logger.warning("Notion not configured, skipping upload")
return False
if not REQUESTS_AVAILABLE:
self.logger.error("requests library not available for Notion upload")
return False
if not pdf_path.exists():
self.logger.error(f"PDF file not found: {pdf_path}")
return False
try:
# For simplicity, we'll create a page with just the title and a link placeholder
# In a real implementation, you'd need to upload the file to Notion's file storage
page_data = {
"parent": {"database_id": self._database_id},
"properties": {
"Name": {
"title": [
{
"text": {
"content": title
}
}
]
},
"Status": {
"select": {
"name": "Procesado"
}
},
"File Path": {
"rich_text": [
{
"text": {
"content": str(pdf_path)
}
}
]
}
}
}
response = requests.post(
f"{self._base_url}/pages",
headers=self._get_headers(),
json=page_data,
timeout=30
)
if response.status_code == 200:
self.logger.info(f"PDF uploaded to Notion successfully: {title}")
return True
else:
self.logger.error(f"Notion API error: {response.status_code} - {response.text}")
return False
except Exception as e:
self.logger.error(f"Error uploading PDF to Notion: {e}")
return False
# Global instance
notion_service = NotionService()
def upload_to_notion(pdf_path: Path, title: str) -> bool:
"""Legacy function for backward compatibility"""
return notion_service.upload_pdf(pdf_path, title)

View File

@@ -7,8 +7,9 @@ import time
import unicodedata import unicodedata
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict from typing import Optional, List, Dict, Tuple
from contextlib import contextmanager from contextlib import contextmanager
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests import requests
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
@@ -107,7 +108,7 @@ class WebDAVService:
return self._parse_propfind_response(response.text) return self._parse_propfind_response(response.text)
def _parse_propfind_response(self, xml_response: str) -> List[str]: def _parse_propfind_response(self, xml_response: str) -> List[str]:
"""Parse PROPFIND XML response""" """Parse PROPFIND XML response and return only files (not directories)"""
# Simple parser for PROPFIND response # Simple parser for PROPFIND response
files = [] files = []
try: try:
@@ -119,20 +120,41 @@ class WebDAVService:
parsed_url = urlparse(settings.NEXTCLOUD_URL) parsed_url = urlparse(settings.NEXTCLOUD_URL)
webdav_path = parsed_url.path.rstrip('/') # e.g. /remote.php/webdav webdav_path = parsed_url.path.rstrip('/') # e.g. /remote.php/webdav
# Find all href elements # Find all response elements
for href in root.findall('.//{DAV:}href'): for response in root.findall('.//{DAV:}response'):
href_text = href.text or "" href = response.find('.//{DAV:}href')
href_text = unquote(href_text) # Decode URL encoding if href is None or href.text is None:
continue
href_text = unquote(href.text) # Decode URL encoding
# Check if this is a directory (has collection resourcetype)
propstat = response.find('.//{DAV:}propstat')
is_directory = False
if propstat is not None:
prop = propstat.find('.//{DAV:}prop')
if prop is not None:
resourcetype = prop.find('.//{DAV:}resourcetype')
if resourcetype is not None and resourcetype.find('.//{DAV:}collection') is not None:
is_directory = True
# Skip directories
if is_directory:
continue
# Also skip paths ending with / (another way to detect directories)
if href_text.endswith('/'):
continue
# Remove base URL from href # Remove base URL from href
base_url = settings.NEXTCLOUD_URL.rstrip('/') base_url = settings.NEXTCLOUD_URL.rstrip('/')
if href_text.startswith(base_url): if href_text.startswith(base_url):
href_text = href_text[len(base_url):] href_text = href_text[len(base_url):]
# Also strip the webdav path if it's there # Also strip the webdav path if it's there
if href_text.startswith(webdav_path): if href_text.startswith(webdav_path):
href_text = href_text[len(webdav_path):] href_text = href_text[len(webdav_path):]
# Clean up the path # Clean up the path
href_text = href_text.lstrip('/') href_text = href_text.lstrip('/')
if href_text: # Skip empty paths (root directory) if href_text: # Skip empty paths (root directory)
@@ -210,6 +232,59 @@ class WebDAVService:
except WebDAVError: except WebDAVError:
return False return False
def upload_batch(
self,
files: List[Tuple[Path, str]],
max_workers: int = 4,
timeout: int = 120
) -> Dict[str, bool]:
"""
Upload multiple files concurrently.
Args:
files: List of (local_path, remote_path) tuples
max_workers: Maximum concurrent uploads
timeout: Timeout per upload in seconds
Returns:
Dict mapping remote_path to success status
"""
if not files:
return {}
results: Dict[str, bool] = {}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all upload tasks
future_to_path = {
executor.submit(self.upload, local, remote): remote
for local, remote in files
}
# Collect results as they complete
for future in as_completed(future_to_path, timeout=timeout):
remote_path = future_to_path[future]
try:
future.result()
results[remote_path] = True
self.logger.info(f"Successfully uploaded: {remote_path}")
except Exception as e:
results[remote_path] = False
self.logger.error(f"Failed to upload {remote_path}: {e}")
failed_count = sum(1 for success in results.values() if not success)
if failed_count > 0:
self.logger.warning(
f"Batch upload completed with {failed_count} failures "
f"({len(results) - failed_count}/{len(results)} successful)"
)
else:
self.logger.info(
f"Batch upload completed: {len(results)} files uploaded successfully"
)
return results
# Global instance # Global instance
webdav_service = WebDAVService() webdav_service = WebDAVService()

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""
Script para verificar y configurar permisos de Notion
"""
import sys
import logging
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from config import settings
from notion_client import Client
logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def main():
print("\n" + "=" * 60)
print("🔧 VERIFICACIÓN DE PERMISOS DE NOTION")
print("=" * 60 + "\n")
# Configuración
token = settings.NOTION_API_TOKEN
database_id = settings.NOTION_DATABASE_ID
if not token or not database_id:
print("❌ Falta configuración de Notion en .env")
print(f" NOTION_API: {'' if token else ''}")
print(f" NOTION_DATABASE_ID: {'' if database_id else ''}")
return
print(f"✅ Token configurado: {token[:20]}...")
print(f"✅ Database ID: {database_id}\n")
# Crear cliente
client = Client(auth=token)
print("📋 PASOS PARA CONFIGURAR LOS PERMISOS:\n")
print("1. Abre Notion y ve a tu base de datos 'CBC'")
print(f" URL: https://www.notion.so/{database_id}")
print("\n2. Click en los 3 puntos (⋯) en la esquina superior derecha")
print("\n3. Selecciona 'Connections' o 'Añadir conexiones'")
print("\n4. Busca tu integración y actívala")
print(f" (Debería aparecer con el nombre que le pusiste)")
print("\n5. Confirma los permisos\n")
print("-" * 60)
print("\n🧪 Intentando conectar con Notion...\n")
try:
# Intentar obtener la base de datos
database = client.databases.retrieve(database_id=database_id)
print("✅ ¡ÉXITO! La integración puede acceder a la base de datos")
print(f"\n📊 Información de la base de datos:")
print(
f" Título: {database['title'][0]['plain_text'] if database.get('title') else 'Sin título'}"
)
print(f" ID: {database['id']}")
print(f"\n Propiedades disponibles:")
for prop_name, prop_data in database.get("properties", {}).items():
prop_type = prop_data.get("type", "unknown")
print(f" - {prop_name}: {prop_type}")
print("\n" + "=" * 60)
print("✅ TODO CONFIGURADO CORRECTAMENTE")
print("=" * 60 + "\n")
print("🚀 Ahora ejecuta: python test_notion_integration.py")
print(" para probar subir un documento\n")
except Exception as e:
error_msg = str(e)
print("❌ ERROR AL CONECTAR CON NOTION\n")
print(f"Error: {error_msg}\n")
if "Could not find database" in error_msg:
print("⚠️ LA BASE DE DATOS NO ESTÁ COMPARTIDA CON TU INTEGRACIÓN")
print("\nSigue los pasos arriba para compartir la base de datos.")
elif "Unauthorized" in error_msg or "401" in error_msg:
print("⚠️ EL TOKEN DE API ES INVÁLIDO")
print("\nVerifica que el token esté correcto en .env")
else:
print("⚠️ ERROR DESCONOCIDO")
print(f"\nDetalles: {error_msg}")
print("\n" + "=" * 60 + "\n")
if __name__ == "__main__":
main()