Files
cbc2027/todo.md
renato97 f04c1cd548 feat: dashboard integrado en thread separado + documentación
🚀 Mejoras principales:
- Dashboard Flask ahora corre en thread daemon independiente
- Integración con python-dotenv para variables de entorno
- Configuración de puerto vía DASHBOARD_PORT (default: 5000)
- Mejor logging con Thread-ID para debugging

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

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

Esto permite que el dashboard corra sin bloquear el loop principal
de procesamiento, mejorando la arquitectura del servicio.
2026-01-10 19:32:08 +00:00

37 KiB

🎯 TODO: Dashboard de Monitoreo y Reprocesamiento

📋 Objetivo Principal

Crear un dashboard funcional en localhost que permita:

  1. Monitorear archivos procesados y pendientes
  2. Visualizar transcripciones y resúmenes generados
  3. Regenerar resúmenes cuando no son satisfactorios (usando la transcripción existente)
  4. Reprocesar archivos completos si es necesario

📊 Estado Actual del Proyecto

Lo que YA existe:

Componente Ubicación Estado
UI Dashboard templates/index.html Completo (1586 líneas)
API Routes api/routes.py Parcial (281 líneas)
Backend Flask api/__init__.py Básico
Generador Docs document/generators.py Completo
Audio Processor processors/audio_processor.py Completo
PDF Processor processors/pdf_processor.py Completo
AI Providers services/ai/ Completo

Lo que FALTA implementar:

Funcionalidad Prioridad Complejidad
Endpoint regenerar resumen 🔴 Alta Media
Vista previa de transcripción 🔴 Alta Baja
Vista previa de resumen 🔴 Alta Baja
Editor de prompts 🟡 Media Media
Historial de versiones 🟢 Baja Alta

🏗️ ARQUITECTURA DEL DASHBOARD

┌─────────────────────────────────────────────────────────────────────┐
│                        FRONTEND (index.html)                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐ │
│  │  Stats      │  │  File List  │  │  Preview    │  │  Actions    │ │
│  │  Cards      │  │  + Filters  │  │  Panel      │  │  Panel      │ │
│  └─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                         API ROUTES (routes.py)                       │
│  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐   │
│  │ GET /api/files   │  │ GET /api/preview │  │ POST /api/regen  │   │
│  └──────────────────┘  └──────────────────┘  └──────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                       BACKEND SERVICES                               │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐               │
│  │ Document     │  │ AI Provider  │  │ WebDAV       │               │
│  │ Generator    │  │ (Gemini)     │  │ Service      │               │
│  └──────────────┘  └──────────────┘  └──────────────┘               │
└─────────────────────────────────────────────────────────────────────┘

📁 ARCHIVOS A MODIFICAR/CREAR

1. api/routes.py - Nuevos Endpoints

1.1 Endpoint: Obtener contenido de transcripción

GET /api/transcription/<filename>

Propósito: Devolver el contenido de la transcripción (.txt) de un archivo de audio.

Request:

GET /api/transcription/clase_historia_01

Response:

{
  "success": true,
  "filename": "clase_historia_01",
  "transcription": "Contenido completo de la transcripción...",
  "file_path": "/home/ren/proyectos/cbc/downloads/clase_historia_01.txt",
  "word_count": 3456,
  "char_count": 18234
}

Implementación:

@app.route('/api/transcription/<filename>')
def get_transcription(filename):
    """Obtener contenido de transcripción"""
    try:
        # Buscar archivo .txt en downloads/
        txt_path = settings.LOCAL_DOWNLOADS_PATH / f"{filename}.txt"
        
        if not txt_path.exists():
            return jsonify({
                'success': False,
                'message': f"Transcripción no encontrada: {filename}.txt"
            }), 404
        
        with open(txt_path, 'r', encoding='utf-8') as f:
            content = f.read()
        
        return jsonify({
            'success': True,
            'filename': filename,
            'transcription': content,
            'file_path': str(txt_path),
            'word_count': len(content.split()),
            'char_count': len(content)
        })
    except Exception as e:
        app.logger.error(f"Error getting transcription: {e}")
        return jsonify({
            'success': False,
            'message': f"Error: {str(e)}"
        }), 500

1.2 Endpoint: Obtener contenido de resumen

GET /api/summary/<filename>

Propósito: Devolver el contenido del resumen (.md) de un archivo procesado.

Request:

GET /api/summary/clase_historia_01_unificado

Response:

{
  "success": true,
  "filename": "clase_historia_01_unificado",
  "summary": "# Resumen\n\n## Contenido...",
  "file_path": "/home/ren/proyectos/cbc/downloads/clase_historia_01_unificado.md",
  "formats_available": {
    "md": true,
    "docx": true,
    "pdf": true
  }
}

Implementación:

@app.route('/api/summary/<filename>')
def get_summary(filename):
    """Obtener contenido de resumen"""
    try:
        # Normalizar nombre (agregar _unificado si no lo tiene)
        base_name = filename.replace('_unificado', '')
        unified_name = f"{base_name}_unificado"
        
        # Buscar archivo .md en downloads/
        md_path = settings.LOCAL_DOWNLOADS_PATH / f"{unified_name}.md"
        
        if not md_path.exists():
            return jsonify({
                'success': False,
                'message': f"Resumen no encontrado: {unified_name}.md"
            }), 404
        
        with open(md_path, 'r', encoding='utf-8') as f:
            content = f.read()
        
        # Verificar formatos disponibles
        formats = {
            'md': md_path.exists(),
            'docx': (settings.LOCAL_DOCX / f"{unified_name}.docx").exists(),
            'pdf': (settings.LOCAL_DOWNLOADS_PATH / f"{unified_name}.pdf").exists()
        }
        
        return jsonify({
            'success': True,
            'filename': unified_name,
            'summary': content,
            'file_path': str(md_path),
            'formats_available': formats
        })
    except Exception as e:
        app.logger.error(f"Error getting summary: {e}")
        return jsonify({
            'success': False,
            'message': f"Error: {str(e)}"
        }), 500

1.3 Endpoint: Regenerar resumen (CRÍTICO)

POST /api/regenerate-summary

Propósito: Regenerar el resumen usando la transcripción existente, sin reprocesar el audio.

Request:

{
  "filename": "clase_historia_01",
  "custom_prompt": "Opcional: prompt personalizado para el resumen",
  "ai_provider": "gemini"
}

Response:

{
  "success": true,
  "message": "Resumen regenerado exitosamente",
  "new_summary": "# Nuevo Resumen...",
  "files_updated": {
    "md": "/path/to/clase_historia_01_unificado.md",
    "docx": "/path/to/clase_historia_01_unificado.docx",
    "pdf": "/path/to/clase_historia_01_unificado.pdf"
  },
  "processing_time": 4.5
}

Implementación detallada:

@app.route('/api/regenerate-summary', methods=['POST'])
def regenerate_summary():
    """Regenerar resumen desde transcripción existente"""
    try:
        data = request.get_json()
        filename = data.get('filename')
        custom_prompt = data.get('custom_prompt', None)
        
        if not filename:
            return jsonify({
                'success': False,
                'message': "Nombre de archivo requerido"
            }), 400
        
        # Paso 1: Obtener transcripción
        base_name = filename.replace('_unificado', '').replace('.txt', '').replace('.md', '')
        txt_path = settings.LOCAL_DOWNLOADS_PATH / f"{base_name}.txt"
        
        if not txt_path.exists():
            return jsonify({
                'success': False,
                'message': f"Transcripción no encontrada: {base_name}.txt"
            }), 404
        
        with open(txt_path, 'r', encoding='utf-8') as f:
            transcription_text = f.read()
        
        if not transcription_text.strip():
            return jsonify({
                'success': False,
                'message': "La transcripción está vacía"
            }), 400
        
        # Paso 2: Regenerar resumen con IA
        import time
        start_time = time.time()
        
        from document.generators import DocumentGenerator
        doc_generator = DocumentGenerator()
        
        success, summary, output_files = doc_generator.generate_summary(
            transcription_text, 
            base_name
        )
        
        processing_time = time.time() - start_time
        
        if not success:
            return jsonify({
                'success': False,
                'message': "Error al generar el resumen con IA"
            }), 500
        
        # Paso 3: Subir a Nextcloud si está configurado
        if settings.has_webdav_config:
            from services.webdav_service import webdav_service
            
            for folder in [settings.RESUMENES_FOLDER, settings.DOCX_FOLDER]:
                try:
                    webdav_service.makedirs(folder)
                except Exception:
                    pass
            
            # Subir MD
            md_path = Path(output_files.get('markdown_path', ''))
            if md_path.exists():
                remote_md = f"{settings.RESUMENES_FOLDER}/{md_path.name}"
                webdav_service.upload(md_path, remote_md)
            
            # Subir DOCX
            docx_path = Path(output_files.get('docx_path', ''))
            if docx_path.exists():
                remote_docx = f"{settings.DOCX_FOLDER}/{docx_path.name}"
                webdav_service.upload(docx_path, remote_docx)
            
            # Subir PDF
            pdf_path = Path(output_files.get('pdf_path', ''))
            if pdf_path.exists():
                remote_pdf = f"{settings.DOCX_FOLDER}/{pdf_path.name}"
                webdav_service.upload(pdf_path, remote_pdf)
        
        return jsonify({
            'success': True,
            'message': "Resumen regenerado exitosamente",
            'new_summary': summary[:500] + "..." if len(summary) > 500 else summary,
            'files_updated': output_files,
            'processing_time': round(processing_time, 2)
        })
        
    except Exception as e:
        app.logger.error(f"Error regenerating summary: {e}")
        return jsonify({
            'success': False,
            'message': f"Error: {str(e)}"
        }), 500

1.4 Endpoint: Listar todos los archivos procesados con detalles

GET /api/files-detailed

Propósito: Obtener lista de archivos con información sobre transcripciones y resúmenes disponibles.

Response:

{
  "success": true,
  "files": [
    {
      "filename": "clase_historia_01.mp3",
      "base_name": "clase_historia_01",
      "audio_path": "/path/to/audio.mp3",
      "has_transcription": true,
      "transcription_path": "/path/to/clase_historia_01.txt",
      "transcription_words": 3456,
      "has_summary": true,
      "summary_path": "/path/to/clase_historia_01_unificado.md",
      "formats": {
        "txt": true,
        "md": true,
        "docx": true,
        "pdf": true
      },
      "processed": true,
      "last_modified": "2024-01-10 15:30:00"
    }
  ]
}

Implementación:

@app.route('/api/files-detailed')
def get_files_detailed():
    """Obtener lista detallada de archivos con transcripciones y resúmenes"""
    try:
        files = []
        downloads_path = settings.LOCAL_DOWNLOADS_PATH
        docx_path = settings.LOCAL_DOCX
        
        # Buscar archivos de audio
        for ext in settings.AUDIO_EXTENSIONS:
            for audio_file in downloads_path.glob(f"*{ext}"):
                base_name = audio_file.stem
                
                # Verificar transcripción
                txt_path = downloads_path / f"{base_name}.txt"
                has_transcription = txt_path.exists()
                transcription_words = 0
                if has_transcription:
                    with open(txt_path, 'r', encoding='utf-8') as f:
                        transcription_words = len(f.read().split())
                
                # Verificar resumen
                md_path = downloads_path / f"{base_name}_unificado.md"
                has_summary = md_path.exists()
                
                # Verificar formatos
                formats = {
                    'txt': has_transcription,
                    'md': has_summary,
                    'docx': (docx_path / f"{base_name}_unificado.docx").exists(),
                    'pdf': (downloads_path / f"{base_name}_unificado.pdf").exists()
                }
                
                files.append({
                    'filename': audio_file.name,
                    'base_name': base_name,
                    'audio_path': str(audio_file),
                    'has_transcription': has_transcription,
                    'transcription_path': str(txt_path) if has_transcription else None,
                    'transcription_words': transcription_words,
                    'has_summary': has_summary,
                    'summary_path': str(md_path) if has_summary else None,
                    'formats': formats,
                    'processed': has_transcription and has_summary,
                    'last_modified': datetime.fromtimestamp(
                        audio_file.stat().st_mtime
                    ).strftime('%Y-%m-%d %H:%M:%S')
                })
        
        return jsonify({
            'success': True,
            'files': sorted(files, key=lambda x: x['last_modified'], reverse=True),
            'total': len(files),
            'with_transcription': sum(1 for f in files if f['has_transcription']),
            'with_summary': sum(1 for f in files if f['has_summary'])
        })
        
    except Exception as e:
        app.logger.error(f"Error getting detailed files: {e}")
        return jsonify({
            'success': False,
            'message': f"Error: {str(e)}"
        }), 500

2. templates/index.html - Nuevos Componentes UI

2.1 Panel de Preview (Sidebar Derecho)

Ubicación: Después del files-container existente

HTML a agregar (después de línea 1093):

<!-- Panel de Preview -->
<div class="preview-panel" id="previewPanel">
    <div class="preview-header">
        <h3 id="previewTitle">Selecciona un archivo</h3>
        <button class="close-preview-btn" onclick="closePreview()"></button>
    </div>
    
    <div class="preview-tabs">
        <button class="tab-btn active" data-tab="transcription" onclick="switchTab('transcription')">
            📝 Transcripción
        </button>
        <button class="tab-btn" data-tab="summary" onclick="switchTab('summary')">
            📄 Resumen
        </button>
    </div>
    
    <div class="preview-content">
        <div class="tab-content active" id="transcriptionTab">
            <div class="content-stats">
                <span id="wordCount">0 palabras</span>
                <span id="charCount">0 caracteres</span>
            </div>
            <div class="text-preview" id="transcriptionText">
                Haz clic en un archivo para ver su transcripción
            </div>
        </div>
        
        <div class="tab-content" id="summaryTab">
            <div class="markdown-preview" id="summaryText">
                Haz clic en un archivo para ver su resumen
            </div>
        </div>
    </div>
    
    <div class="preview-actions">
        <button class="action-btn regenerate-btn" id="regenerateBtn" onclick="regenerateSummary()" disabled>
            🔄 Regenerar Resumen
        </button>
        <button class="action-btn download-btn" id="downloadBtn" onclick="downloadFiles()" disabled>
            ⬇️ Descargar
        </button>
    </div>
    
    <!-- Modal de progreso -->
    <div class="regenerate-progress" id="regenerateProgress">
        <div class="progress-spinner"></div>
        <p>Regenerando resumen con IA...</p>
        <small>Esto puede tomar 10-30 segundos</small>
    </div>
</div>

2.2 Estilos CSS para el Panel de Preview

Agregar al bloque <style> (antes de línea 1009):

/* ================================================
   PANEL DE PREVIEW - SIDEBAR DERECHO
   ================================================ */

.preview-panel {
    position: fixed;
    top: 0;
    right: -500px;
    width: 500px;
    height: 100vh;
    background: var(--bg-secondary);
    border-left: 1px solid var(--border-color);
    box-shadow: var(--shadow-xl);
    z-index: 1000;
    display: flex;
    flex-direction: column;
    transition: right 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}

.preview-panel.active {
    right: 0;
}

.preview-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 25px 30px;
    border-bottom: 1px solid var(--border-color);
    background: var(--bg-tertiary);
}

.preview-header h3 {
    font-size: 1.3rem;
    font-weight: 700;
    color: var(--text-primary);
    margin: 0;
    max-width: 380px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.close-preview-btn {
    background: none;
    border: none;
    color: var(--text-secondary);
    font-size: 1.5rem;
    cursor: pointer;
    padding: 5px 10px;
    border-radius: 8px;
    transition: all 0.3s ease;
}

.close-preview-btn:hover {
    background: var(--bg-hover);
    color: var(--error-color);
}

/* Tabs */
.preview-tabs {
    display: flex;
    padding: 15px 20px;
    gap: 10px;
    border-bottom: 1px solid var(--border-color);
    background: var(--bg-secondary);
}

.tab-btn {
    flex: 1;
    padding: 12px 20px;
    background: var(--bg-tertiary);
    border: 1px solid var(--border-color);
    border-radius: 10px;
    color: var(--text-secondary);
    font-size: 0.95rem;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s ease;
    font-family: 'Inter', sans-serif;
}

.tab-btn:hover {
    background: var(--bg-hover);
    color: var(--text-primary);
    border-color: var(--accent-color);
}

.tab-btn.active {
    background: var(--accent-color);
    color: white;
    border-color: var(--accent-color);
}

/* Content */
.preview-content {
    flex: 1;
    overflow: hidden;
    position: relative;
}

.tab-content {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 20px;
    overflow-y: auto;
    display: none;
}

.tab-content.active {
    display: block;
    animation: fadeIn 0.3s ease;
}

.content-stats {
    display: flex;
    gap: 20px;
    padding: 10px 15px;
    background: var(--bg-tertiary);
    border-radius: 10px;
    margin-bottom: 15px;
    font-size: 0.9rem;
    color: var(--text-secondary);
}

.content-stats span {
    display: flex;
    align-items: center;
    gap: 5px;
}

.text-preview, .markdown-preview {
    background: var(--bg-tertiary);
    border: 1px solid var(--border-color);
    border-radius: 12px;
    padding: 20px;
    font-size: 0.95rem;
    line-height: 1.8;
    color: var(--text-primary);
    white-space: pre-wrap;
    word-wrap: break-word;
    max-height: calc(100vh - 350px);
    overflow-y: auto;
}

.markdown-preview h1 { font-size: 1.5rem; margin: 20px 0 10px 0; color: var(--accent-color); }
.markdown-preview h2 { font-size: 1.3rem; margin: 18px 0 8px 0; color: var(--text-primary); }
.markdown-preview h3 { font-size: 1.1rem; margin: 15px 0 6px 0; color: var(--text-secondary); }
.markdown-preview p { margin: 10px 0; }
.markdown-preview ul, .markdown-preview ol { padding-left: 25px; margin: 10px 0; }
.markdown-preview li { margin: 5px 0; }
.markdown-preview hr { border: none; border-top: 1px solid var(--border-color); margin: 20px 0; }

/* Actions */
.preview-actions {
    display: flex;
    gap: 12px;
    padding: 20px;
    border-top: 1px solid var(--border-color);
    background: var(--bg-tertiary);
}

.regenerate-btn {
    flex: 2;
    background: linear-gradient(135deg, var(--warning-color) 0%, #ffb84d 100%) !important;
    color: #000 !important;
}

.regenerate-btn:hover:not(:disabled) {
    box-shadow: 0 8px 25px rgba(249, 168, 38, 0.5) !important;
}

.download-btn {
    flex: 1;
    background: var(--bg-secondary) !important;
    border: 1px solid var(--border-color) !important;
    color: var(--text-primary) !important;
}

.download-btn:hover:not(:disabled) {
    border-color: var(--accent-color) !important;
    background: var(--bg-hover) !important;
}

/* Progress overlay */
.regenerate-progress {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(15, 17, 21, 0.95);
    display: none;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    gap: 20px;
    z-index: 100;
}

.regenerate-progress.active {
    display: flex;
}

.progress-spinner {
    width: 60px;
    height: 60px;
    border: 4px solid var(--border-color);
    border-top-color: var(--accent-color);
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

.regenerate-progress p {
    font-size: 1.2rem;
    font-weight: 600;
    color: var(--text-primary);
}

.regenerate-progress small {
    color: var(--text-tertiary);
}

/* Responsive */
@media (max-width: 768px) {
    .preview-panel {
        width: 100%;
        right: -100%;
    }
}

2.3 JavaScript para el Panel de Preview

Agregar al bloque <script> (antes de línea 1584):

// ================================================
// PANEL DE PREVIEW - FUNCIONES
// ================================================

let currentPreviewFile = null;

// Abrir panel de preview al hacer clic en un archivo
function openPreview(filename) {
    currentPreviewFile = filename;
    const panel = document.getElementById('previewPanel');
    const title = document.getElementById('previewTitle');
    
    // Actualizar título
    title.textContent = filename;
    
    // Cargar transcripción
    loadTranscription(filename);
    
    // Cargar resumen
    loadSummary(filename);
    
    // Mostrar panel
    panel.classList.add('active');
    
    // Activar botones si hay transcripción
    document.getElementById('regenerateBtn').disabled = false;
    document.getElementById('downloadBtn').disabled = false;
}

// Cerrar panel de preview
function closePreview() {
    const panel = document.getElementById('previewPanel');
    panel.classList.remove('active');
    currentPreviewFile = null;
}

// Cambiar tab
function switchTab(tabName) {
    // Actualizar botones de tab
    document.querySelectorAll('.tab-btn').forEach(btn => {
        btn.classList.toggle('active', btn.dataset.tab === tabName);
    });
    
    // Actualizar contenido
    document.querySelectorAll('.tab-content').forEach(content => {
        content.classList.toggle('active', content.id === `${tabName}Tab`);
    });
}

// Cargar transcripción
async function loadTranscription(filename) {
    const baseName = filename.replace(/\.[^/.]+$/, ''); // Quitar extensión
    
    try {
        const response = await fetch(`/api/transcription/${encodeURIComponent(baseName)}`);
        const data = await response.json();
        
        if (data.success) {
            document.getElementById('transcriptionText').textContent = data.transcription;
            document.getElementById('wordCount').textContent = `${data.word_count} palabras`;
            document.getElementById('charCount').textContent = `${data.char_count} caracteres`;
        } else {
            document.getElementById('transcriptionText').textContent = 
                '⚠️ Transcripción no disponible. Procesa el archivo primero.';
            document.getElementById('wordCount').textContent = '0 palabras';
            document.getElementById('charCount').textContent = '0 caracteres';
        }
    } catch (error) {
        console.error('Error loading transcription:', error);
        document.getElementById('transcriptionText').textContent = 
            '❌ Error al cargar la transcripción';
    }
}

// Cargar resumen
async function loadSummary(filename) {
    const baseName = filename.replace(/\.[^/.]+$/, ''); // Quitar extensión
    
    try {
        const response = await fetch(`/api/summary/${encodeURIComponent(baseName)}`);
        const data = await response.json();
        
        if (data.success) {
            // Renderizar Markdown a HTML básico
            document.getElementById('summaryText').innerHTML = renderMarkdown(data.summary);
        } else {
            document.getElementById('summaryText').innerHTML = 
                '<p style="color: var(--warning-color);">⚠️ Resumen no disponible. Genera un resumen primero.</p>';
        }
    } catch (error) {
        console.error('Error loading summary:', error);
        document.getElementById('summaryText').innerHTML = 
            '<p style="color: var(--error-color);">❌ Error al cargar el resumen</p>';
    }
}

// Renderizar Markdown básico a HTML
function renderMarkdown(text) {
    if (!text) return '';
    
    return text
        // Headers
        .replace(/^### (.*$)/gm, '<h3>$1</h3>')
        .replace(/^## (.*$)/gm, '<h2>$1</h2>')
        .replace(/^# (.*$)/gm, '<h1>$1</h1>')
        // Bold
        .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
        // Italic
        .replace(/\*(.*?)\*/g, '<em>$1</em>')
        // Lists
        .replace(/^\- (.*$)/gm, '<li>$1</li>')
        .replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
        // Line breaks
        .replace(/\n\n/g, '</p><p>')
        .replace(/\n/g, '<br>')
        // Horizontal rule
        .replace(/---/g, '<hr>')
        // Wrap in paragraph
        .replace(/^(.+)$/gm, '<p>$1</p>')
        // Clean up empty paragraphs
        .replace(/<p><\/p>/g, '')
        .replace(/<p><h/g, '<h')
        .replace(/<\/h(\d)><\/p>/g, '</h$1>')
        .replace(/<p><ul>/g, '<ul>')
        .replace(/<\/ul><\/p>/g, '</ul>')
        .replace(/<p><hr><\/p>/g, '<hr>');
}

// Regenerar resumen
async function regenerateSummary() {
    if (!currentPreviewFile) return;
    
    const baseName = currentPreviewFile.replace(/\.[^/.]+$/, '');
    
    // Confirmar acción
    const confirmed = await showConfirmDialog(
        '🔄 Regenerar Resumen',
        `¿Estás seguro de que quieres regenerar el resumen de "${baseName}"?\n\n` +
        'Esto sobrescribirá el resumen actual con uno nuevo generado por IA.'
    );
    
    if (!confirmed) return;
    
    // Mostrar progreso
    const progressEl = document.getElementById('regenerateProgress');
    progressEl.classList.add('active');
    
    try {
        const response = await fetch('/api/regenerate-summary', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                filename: baseName
            })
        });
        
        const data = await response.json();
        
        if (data.success) {
            showMessage(`✅ Resumen regenerado en ${data.processing_time}s`, 'success');
            
            // Recargar resumen
            await loadSummary(currentPreviewFile);
            
            // Cambiar a tab de resumen
            switchTab('summary');
            
            // Recargar lista de archivos
            await loadFiles();
        } else {
            showMessage('❌ ' + data.message, 'error');
        }
    } catch (error) {
        console.error('Error regenerating summary:', error);
        showMessage('❌ Error de conexión al regenerar', 'error');
    } finally {
        progressEl.classList.remove('active');
    }
}

// Descargar archivos
function downloadFiles() {
    if (!currentPreviewFile) return;
    
    const baseName = currentPreviewFile.replace(/\.[^/.]+$/, '');
    
    // Abrir menú de descargas
    const formats = ['txt', 'md', 'docx', 'pdf'];
    const menu = document.createElement('div');
    menu.className = 'download-menu';
    menu.innerHTML = `
        <div style="
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: var(--bg-secondary);
            border: 1px solid var(--border-color);
            border-radius: 16px;
            padding: 30px;
            z-index: 10001;
            box-shadow: var(--shadow-xl);
        ">
            <h3 style="margin-bottom: 20px; color: var(--text-primary);">⬇️ Descargar Archivos</h3>
            <div style="display: flex; flex-direction: column; gap: 10px;">
                ${formats.map(fmt => `
                    <a href="/downloads/${baseName}_unificado.${fmt}" 
                       target="_blank" 
                       style="
                           padding: 12px 20px;
                           background: var(--bg-tertiary);
                           border: 1px solid var(--border-color);
                           border-radius: 10px;
                           color: var(--text-primary);
                           text-decoration: none;
                           display: flex;
                           align-items: center;
                           gap: 10px;
                           transition: all 0.3s ease;
                       "
                       onmouseover="this.style.borderColor='var(--accent-color)'"
                       onmouseout="this.style.borderColor='var(--border-color)'"
                    >
                        ${fmt === 'txt' ? '📝' : fmt === 'md' ? '📋' : fmt === 'docx' ? '📄' : '📑'}
                        ${fmt.toUpperCase()}
                    </a>
                `).join('')}
            </div>
            <button onclick="this.parentElement.parentElement.remove()" 
                    style="
                        margin-top: 20px;
                        width: 100%;
                        padding: 12px;
                        background: var(--bg-tertiary);
                        border: 1px solid var(--border-color);
                        border-radius: 10px;
                        color: var(--text-primary);
                        cursor: pointer;
                        font-family: 'Inter', sans-serif;
                    "
            >Cerrar</button>
        </div>
        <div style="
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: rgba(0,0,0,0.7);
            z-index: 10000;
        " onclick="this.parentElement.remove()"></div>
    `;
    document.body.appendChild(menu);
}

2.4 Modificar renderFiles() para agregar click handler

Buscar y modificar en la función renderFiles() (alrededor de línea 1171):

ANTES:

return `
    <div class="file-card" data-filename="${file.filename.toLowerCase()}">

DESPUÉS:

return `
    <div class="file-card" data-filename="${file.filename.toLowerCase()}" onclick="openPreview('${file.filename}')">

3. Iniciar el Dashboard

3.1 Crear script de inicio run_dashboard.py

Ubicación: /home/ren/proyectos/cbc/run_dashboard.py

#!/usr/bin/env python3
"""
Iniciar el dashboard de CBCFacil
"""
import os
import sys

# Agregar directorio raíz al path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

# Cargar variables de entorno
from dotenv import load_dotenv
load_dotenv()

# Importar y crear app Flask
from api.routes import create_app

app = create_app()

if __name__ == '__main__':
    print("=" * 60)
    print("  🚀 CBCFacil Dashboard")
    print("  📍 http://localhost:5000")
    print("=" * 60)
    
    app.run(
        host='0.0.0.0',
        port=5000,
        debug=True,
        threaded=True
    )

🔧 INSTRUCCIONES DE IMPLEMENTACIÓN PASO A PASO

FASE 1: Backend - Nuevos Endpoints (30 min)

Archivo: api/routes.py

  1. Agregar import from pathlib import Path si no existe
  2. Agregar import from document.generators import DocumentGenerator
  3. Agregar endpoint GET /api/transcription/<filename>
  4. Agregar endpoint GET /api/summary/<filename>
  5. Agregar endpoint POST /api/regenerate-summary
  6. Agregar endpoint GET /api/files-detailed
  7. Probar cada endpoint con curl:
    curl http://localhost:5000/api/transcription/nombre_archivo
    curl http://localhost:5000/api/summary/nombre_archivo
    curl -X POST http://localhost:5000/api/regenerate-summary \
         -H "Content-Type: application/json" \
         -d '{"filename": "nombre_archivo"}'
    

FASE 2: Frontend - Panel de Preview (45 min)

Archivo: templates/index.html

  1. Agregar HTML del panel de preview después de línea 1093
  2. Agregar estilos CSS antes de línea 1009
  3. Agregar funciones JavaScript antes de línea 1584
  4. Modificar renderFiles() para agregar onclick="openPreview(...)"
  5. Probar en navegador:
    • Hacer clic en un archivo → debe abrir panel
    • Cambiar tabs → debe mostrar transcripción/resumen
    • Botón regenerar → debe llamar API y actualizar

FASE 3: Script de Inicio (10 min)

  1. Crear archivo run_dashboard.py
  2. Hacer ejecutable: chmod +x run_dashboard.py
  3. Probar: python run_dashboard.py

FASE 4: Testing Manual (15 min)

  1. Iniciar dashboard: python run_dashboard.py
  2. Abrir http://localhost:5000
  3. Verificar lista de archivos carga correctamente
  4. Hacer clic en un archivo procesado → verificar que muestra transcripción
  5. Cambiar a tab "Resumen" → verificar que muestra el resumen
  6. Hacer clic en "Regenerar Resumen" → verificar que regenera
  7. Verificar que los archivos .md, .docx, .pdf se actualizan

📝 NOTAS IMPORTANTES PARA EL DESARROLLADOR

Rutas de Archivos

# Transcripciones (.txt)
settings.LOCAL_DOWNLOADS_PATH / f"{base_name}.txt"
# Ejemplo: /home/ren/proyectos/cbc/downloads/clase_historia_01.txt

# Resúmenes Markdown (.md)
settings.LOCAL_DOWNLOADS_PATH / f"{base_name}_unificado.md"
# Ejemplo: /home/ren/proyectos/cbc/downloads/clase_historia_01_unificado.md

# DOCX
settings.LOCAL_DOCX / f"{base_name}_unificado.docx"
# Ejemplo: /home/ren/proyectos/cbc/resumenes_docx/clase_historia_01_unificado.docx

# PDF
settings.LOCAL_DOWNLOADS_PATH / f"{base_name}_unificado.pdf"
# Ejemplo: /home/ren/proyectos/cbc/downloads/clase_historia_01_unificado.pdf

Configuración Necesaria

El archivo .env debe tener configurado:

  • GEMINI_API_KEY o ANTHROPIC_AUTH_TOKEN para regenerar resúmenes
  • NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD (opcional, para sincronizar)

Dependencias Python

# Ya instaladas en requirements.txt
flask
flask-cors
python-docx
reportlab
python-dotenv

CHECKLIST FINAL

Backend

  • Endpoint /api/transcription/<filename> funciona
  • Endpoint /api/summary/<filename> funciona
  • Endpoint /api/regenerate-summary funciona
  • Endpoint /api/files-detailed funciona
  • Manejo de errores correcto (404, 500)
  • Logs de debug activos

Frontend

  • Panel de preview se abre al hacer clic
  • Panel de preview se cierra con X
  • Tabs funcionan (transcripción/resumen)
  • Contador de palabras/caracteres funciona
  • Markdown se renderiza correctamente
  • Botón "Regenerar Resumen" funciona
  • Spinner de progreso aparece
  • Mensajes de éxito/error aparecen
  • Responsive en móvil

Integración

  • Dashboard inicia con python run_dashboard.py
  • Archivos regenerados se suben a Nextcloud (si configurado)
  • No hay errores en consola del navegador
  • No hay errores en logs del servidor

Documento creado para implementación por Minimax M2 Última actualización: 2026-01-10