🚀 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.
1199 lines
37 KiB
Markdown
1199 lines
37 KiB
Markdown
# 🎯 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**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```python
|
|
@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**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```python
|
|
@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**:
|
|
```json
|
|
{
|
|
"filename": "clase_historia_01",
|
|
"custom_prompt": "Opcional: prompt personalizado para el resumen",
|
|
"ai_provider": "gemini"
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```python
|
|
@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**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```python
|
|
@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):
|
|
```html
|
|
<!-- 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):
|
|
```css
|
|
/* ================================================
|
|
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):
|
|
```javascript
|
|
// ================================================
|
|
// 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**:
|
|
```javascript
|
|
return `
|
|
<div class="file-card" data-filename="${file.filename.toLowerCase()}">
|
|
```
|
|
|
|
**DESPUÉS**:
|
|
```javascript
|
|
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`
|
|
|
|
```python
|
|
#!/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:
|
|
```bash
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```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*
|