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