🚀 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.
37 KiB
🎯 TODO: Dashboard de Monitoreo y Reprocesamiento
📋 Objetivo Principal
Crear un dashboard funcional en localhost que permita:
- Monitorear archivos procesados y pendientes
- Visualizar transcripciones y resúmenes generados
- Regenerar resúmenes cuando no son satisfactorios (usando la transcripción existente)
- 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
- Agregar import
from pathlib import Pathsi no existe - Agregar import
from document.generators import DocumentGenerator - Agregar endpoint
GET /api/transcription/<filename> - Agregar endpoint
GET /api/summary/<filename> - Agregar endpoint
POST /api/regenerate-summary - Agregar endpoint
GET /api/files-detailed - 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
- Agregar HTML del panel de preview después de línea 1093
- Agregar estilos CSS antes de línea 1009
- Agregar funciones JavaScript antes de línea 1584
- Modificar
renderFiles()para agregaronclick="openPreview(...)" - 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)
- Crear archivo
run_dashboard.py - Hacer ejecutable:
chmod +x run_dashboard.py - Probar:
python run_dashboard.py
FASE 4: Testing Manual (15 min)
- Iniciar dashboard:
python run_dashboard.py - Abrir http://localhost:5000
- Verificar lista de archivos carga correctamente
- Hacer clic en un archivo procesado → verificar que muestra transcripción
- Cambiar a tab "Resumen" → verificar que muestra el resumen
- Hacer clic en "Regenerar Resumen" → verificar que regenera
- 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_KEYoANTHROPIC_AUTH_TOKENpara regenerar resúmenesNEXTCLOUD_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-summaryfunciona - Endpoint
/api/files-detailedfunciona - 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