Compare commits
4 Commits
ee8fc183be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7726365d7 | ||
|
|
d50772d962 | ||
|
|
d902203b59 | ||
|
|
1f6bfa771b |
@@ -504,7 +504,7 @@ class ProcessManager:
|
||||
# Notificar resumen completado
|
||||
telegram_service.send_summary_complete(filepath.name, has_markdown=True)
|
||||
|
||||
# 4. Llamar a PDFGenerator.markdown_to_pdf()
|
||||
# 4. Llam.markdown_to_pdfar a PDFGenerator()
|
||||
pdf_path = None
|
||||
try:
|
||||
from services.pdf_generator import PDFGenerator
|
||||
@@ -514,7 +514,9 @@ class ProcessManager:
|
||||
|
||||
pdf_generator = PDFGenerator()
|
||||
pdf_path = md_path.with_suffix(".pdf")
|
||||
pdf_generator.markdown_to_pdf(str(md_path), str(pdf_path))
|
||||
# Leer el contenido markdown y pasarlo al generator
|
||||
markdown_content = md_path.read_text(encoding="utf-8")
|
||||
pdf_generator.markdown_to_pdf(markdown_content, pdf_path)
|
||||
|
||||
logger.info(
|
||||
"PDF generado",
|
||||
|
||||
16
docker/.dockerignore
Normal file
16
docker/.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
# Excluir archivos innecesarios del build
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
*.log
|
||||
downloads/
|
||||
transcriptions/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
!docker/README.md
|
||||
node_modules/
|
||||
.DS_Store
|
||||
14
docker/.env.example
Normal file
14
docker/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# CBCFacil - Configuración Docker
|
||||
# Copiar a .env y completar con tus credenciales
|
||||
|
||||
# API Keys
|
||||
ANTHROPIC_AUTH_TOKEN=tu_token_aqui
|
||||
|
||||
# Nextcloud
|
||||
NEXTCLOUD_URL=https://nextcloud.tudominio.com/remote.php/webdav
|
||||
NEXTCLOUD_USER=tu_usuario
|
||||
NEXTCLOUD_PASSWORD=tu_password
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_TOKEN=tu_token_bot
|
||||
TELEGRAM_CHAT_ID=tu_chat_id
|
||||
35
docker/Dockerfile
Normal file
35
docker/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# CBC OpenClaw - Imagen Docker minimal
|
||||
# Solo acceso a /app y las tools necesarias
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Crear usuario no-root para seguridad
|
||||
RUN useradd -m -s /bin/bash cbc && \
|
||||
mkdir -p /home/cbc && \
|
||||
chown -R cbc:cbc /home/cbc
|
||||
|
||||
# Definir workspace
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar solo archivos necesarios del proyecto
|
||||
COPY --chown=cbc:cbc . .
|
||||
|
||||
# Cambiar a usuario no-root
|
||||
USER cbc
|
||||
|
||||
# Variables de entorno para el agente
|
||||
ENV ANTHROPIC_API_KEY=""
|
||||
ENV ANTHROPIC_BASE_URL="https://api.minimax.io/anthropic"
|
||||
ENV ANTHROPIC_MODEL="MiniMax-M2.5"
|
||||
ENV HOME=/app
|
||||
|
||||
# El agente solo puede acceder a /app y sus subdirectorios
|
||||
# No tiene acceso a Internet directo (solo a través de variables de entorno)
|
||||
10
docker/README.md
Normal file
10
docker/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# CBC OpenClaw - Dockerizado con acceso limitado
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
docker/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .dockerignore
|
||||
└── README.md
|
||||
34
docker/docker-compose.yml
Normal file
34
docker/docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
cbc-openclaw:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: cbc-openclaw
|
||||
volumes:
|
||||
# Solo montar las carpetas necesarias
|
||||
- ../:/app
|
||||
# Montar credenciales desde variables de entorno o archivo seguro
|
||||
- ~/.env:/app/.env:ro
|
||||
environment:
|
||||
# API Keys - pasar desde host
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- ANTHROPIC_AUTH_TOKEN=${ANTHROPIC_AUTH_TOKEN}
|
||||
- ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
|
||||
- ANTHROPIC_MODEL=MiniMax-M2.5
|
||||
# Configuración CBC
|
||||
- NEXTCLOUD_URL=${NEXTCLOUD_URL}
|
||||
- NEXTCLOUD_USER=${NEXTCLOUD_USER}
|
||||
- NEXTCLOUD_PASSWORD=${NEXTCLOUD_PASSWORD}
|
||||
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||
working_dir: /app
|
||||
command: ["python3", "main.py"]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cbc-network
|
||||
|
||||
networks:
|
||||
cbc-network:
|
||||
driver: bridge
|
||||
20
docker/start.sh
Executable file
20
docker/start.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# CBC OpenClaw - Script de inicio Docker
|
||||
|
||||
set -e
|
||||
|
||||
# Cargar variables de entorno si existe .env
|
||||
if [ -f ".env" ]; then
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
fi
|
||||
|
||||
echo "🟢 Iniciando CBC OpenClaw..."
|
||||
|
||||
# Construir imagen si no existe
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
# Iniciar contenedor
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
echo "✅ CBC OpenClaw corriendo en http://localhost:5000"
|
||||
echo "📝 Ver logs: docker compose -f docker/docker-compose.yml logs -f"
|
||||
30
main.py
30
main.py
@@ -5,10 +5,15 @@ CBFacil - Sistema de transcripción de audio con IA y Notion
|
||||
Características:
|
||||
- Polling de Nextcloud vía WebDAV
|
||||
- Transcripción con Whisper (medium, GPU)
|
||||
- Resúmenes con IA (GLM-4.7)
|
||||
- Resúmenes con IA (MiniMax)
|
||||
- Generación de PDF
|
||||
- Notificaciones Telegram
|
||||
"""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Cargar variables de entorno desde .env
|
||||
load_dotenv()
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -321,6 +326,13 @@ class PollingService:
|
||||
|
||||
def _on_file_downloaded(self, file_path: Path) -> None:
|
||||
"""Callback when a file is downloaded."""
|
||||
# Verificar si ya fue procesado (existe transcripción con nombre exacto)
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
txt_path = transcriptions_dir / f"{file_path.stem}.txt"
|
||||
if txt_path.exists():
|
||||
logger.info(f"Skipping already processed file: {file_path.name}")
|
||||
return
|
||||
|
||||
self.queue_file_for_processing(file_path)
|
||||
|
||||
def queue_file_for_processing(self, file_path: Path) -> None:
|
||||
@@ -414,10 +426,18 @@ class PollingService:
|
||||
# Extensiones de audio soportadas
|
||||
audio_extensions = {".mp3", ".wav", ".m4a", ".mp4", ".webm", ".ogg", ".flac"}
|
||||
|
||||
pending_files = [
|
||||
f for f in downloads_dir.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in audio_extensions and not f.name.startswith(".")
|
||||
]
|
||||
# Obtener transcripciones existentes - verificar por nombre EXACTO
|
||||
transcriptions_dir = settings.TRANSCRIPTIONS_DIR
|
||||
|
||||
# Filtrar solo archivos que NO han sido procesados
|
||||
pending_files = []
|
||||
for f in downloads_dir.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in audio_extensions and not f.name.startswith("."):
|
||||
# Verificar si ya existe transcripción con el MISMO nombre
|
||||
txt_path = transcriptions_dir / f"{f.stem}.txt"
|
||||
if not txt_path.exists():
|
||||
# No existe .txt, agregar a pendientes
|
||||
pending_files.append(f)
|
||||
|
||||
if not pending_files:
|
||||
logger.debug("No pending audio files to process")
|
||||
|
||||
@@ -69,8 +69,39 @@ class AISummaryService:
|
||||
logger.debug("AISummaryService not configured, returning original text")
|
||||
return text
|
||||
|
||||
default_prompt = "Resume el siguiente texto de manera clara y concisa:"
|
||||
prompt = prompt_template.format(text=text) if prompt_template else f"{default_prompt}\n\n{text}"
|
||||
# Prompt siguiendo código.md - resumen académico en español
|
||||
default_prompt = """Eres un asistente académico especializado en crear resúmenes de estudio de alta calidad.
|
||||
|
||||
INSTRUCCIONES OBLIGATORIAS:
|
||||
1. Escribe ÚNICAMENTE en español
|
||||
2. El resumen debe seguir esta estructura:
|
||||
- Título y objetivo de estudio
|
||||
- Índice con 6-12 secciones
|
||||
- Desarrollo conceptual (definiciones, mecanismos)
|
||||
- Casos de aplicación (ejemplos concretos)
|
||||
- Errores frecuentes
|
||||
- Checklist de repaso
|
||||
3. Cada concepto debe explicar: qué es, por qué importa, cómo se aplica
|
||||
4. Evita listas sin explicación - siempre incluir el "por qué"
|
||||
5. Para TABLAS usa formato LaTeX tabular:
|
||||
\\begin{{tabular}}{{|c|l|l|}}
|
||||
\\hline
|
||||
Encabezado 1 & Encabezado 2 & Encabezado 3 \\\\
|
||||
\\hline
|
||||
dato1 & dato2 & dato3 \\\\
|
||||
\\hline
|
||||
\\end{{tabular}}
|
||||
6. NO uses tablas ASCII ni markdown con | pipes
|
||||
7. El resumen debe poder leerse en 15-25 minutos
|
||||
8. NO incluyas rutas de archivos ni referencias técnicas
|
||||
9. Sé conciso pero con densidad informativa útil para exámenes
|
||||
|
||||
Transcripción de clase:
|
||||
{text}
|
||||
|
||||
Genera el resumen siguiendo las instrucciones arriba."""
|
||||
|
||||
prompt = prompt_template.format(text=text) if prompt_template else default_prompt.format(text=text)
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
@@ -96,6 +127,29 @@ class AISummaryService:
|
||||
result = response.json()
|
||||
|
||||
summary = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# Limpiar respuesta: eliminar thinking tokens y ruido
|
||||
# Buscar el primer encabezado markdown y cortar ahí
|
||||
first_header = summary.find("\n# ")
|
||||
if first_header == -1:
|
||||
first_header = summary.find("# ")
|
||||
if first_header > 0:
|
||||
summary = summary[first_header:]
|
||||
|
||||
# Eliminar bloques de think/error si persisten
|
||||
lines = summary.split("\n")
|
||||
clean_lines = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("<think>") or line.strip().endswith("</think>"):
|
||||
skip = True
|
||||
continue
|
||||
if skip and line.strip() and not line.startswith(" "):
|
||||
skip = False
|
||||
if not skip:
|
||||
clean_lines.append(line)
|
||||
summary = "\n".join(clean_lines)
|
||||
|
||||
logger.info("Summarization completed successfully (output length: %d)", len(summary))
|
||||
return summary
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ Utiliza reportlab para la generación de PDFs con soporte UTF-8.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from typing import Optional, Union
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,6 +64,103 @@ class PDFGenerator:
|
||||
.replace("\n", "<br/>")
|
||||
)
|
||||
|
||||
def _parse_latex_table(self, lines: list[str], start_idx: int) -> tuple[Optional[Table], int]:
|
||||
"""
|
||||
Parsea una tabla LaTeX y la convierte a reportlab Table.
|
||||
|
||||
Returns:
|
||||
(Table, end_index) - La tabla y el índice donde termina
|
||||
"""
|
||||
# Buscar begin/end tabular
|
||||
table_lines = []
|
||||
i = start_idx
|
||||
in_table = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
|
||||
if "\\begin{tabular}" in line or "begin{tabular}" in line:
|
||||
in_table = True
|
||||
# Extraer especificaciones de columnas
|
||||
col_spec = "l"
|
||||
if "{" in line:
|
||||
col_spec = line.split("{")[1].split("}")[0] if "}" in line else "l"
|
||||
table_lines.append({"type": "spec", "data": col_spec})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if "\\end{tabular}" in line or "end{tabular}" in line:
|
||||
in_table = False
|
||||
break
|
||||
|
||||
if in_table:
|
||||
# Saltar líneas de hline
|
||||
if "hline" in line.replace("\\", "").replace(" ", ""):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Procesar línea de tabla
|
||||
# Reemplazar & por separador
|
||||
row_data = line.replace("&", "|")
|
||||
# Eliminar comandos LaTeX
|
||||
row_data = row_data.replace("\\", "").replace("\\\\", "").replace("hline", "")
|
||||
cells = [c.strip() for c in row_data.split("|") if c.strip()]
|
||||
# Filtrar celdas vacías
|
||||
cells = [c for c in cells if c and c != "|"]
|
||||
if cells and len(cells) > 1: # Al menos 2 columnas para ser tabla válida
|
||||
table_lines.append({"type": "row", "data": cells})
|
||||
|
||||
i += 1
|
||||
|
||||
if not table_lines:
|
||||
return None, start_idx
|
||||
|
||||
# Convertir a Table de reportlab
|
||||
data = []
|
||||
col_widths = None
|
||||
|
||||
for tl in table_lines:
|
||||
if tl["type"] == "row":
|
||||
# Limpiar celdas de LaTeX
|
||||
row = []
|
||||
for cell in tl["data"]:
|
||||
cell = cell.strip()
|
||||
# Eliminar comandos LaTeX restantes (manejar {contenido})
|
||||
import re
|
||||
# Eliminar \textbf{...}, \textit{...}, \emph{...}
|
||||
cell = re.sub(r'\\textbf\{([^}]*)\}', r'\1', cell)
|
||||
cell = re.sub(r'\\textit\{([^}]*)\}', r'\1', cell)
|
||||
cell = re.sub(r'\\emph\{([^}]*)\}', r'\1', cell)
|
||||
cell = cell.replace("\\", "").replace("{", "").replace("}", "")
|
||||
cell = cell.strip()
|
||||
if cell:
|
||||
row.append(cell)
|
||||
if row:
|
||||
data.append(row)
|
||||
|
||||
if not data:
|
||||
return None, start_idx
|
||||
|
||||
# Crear tabla
|
||||
try:
|
||||
num_cols = len(data[0]) if data else 1
|
||||
table = Table(data)
|
||||
table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-0, -1), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
]))
|
||||
return table, i
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing LaTeX table: {e}")
|
||||
return None, start_idx
|
||||
|
||||
def _parse_markdown_basic(self, markdown: str) -> list[Paragraph]:
|
||||
"""
|
||||
Convierte markdown básico a una lista de Paragraphs de reportlab.
|
||||
@@ -101,6 +198,14 @@ class PDFGenerator:
|
||||
# Línea horizontal
|
||||
elif line == "---" or line == "***":
|
||||
elements.append(Spacer(1, 0.2 * cm))
|
||||
# Tabla LaTeX
|
||||
elif "begin{tabular}" in line or "begin{tabular" in line:
|
||||
latex_table, end_idx = self._parse_latex_table(lines, idx)
|
||||
if latex_table:
|
||||
elements.append(Spacer(1, 0.3 * cm))
|
||||
elements.append(latex_table)
|
||||
elements.append(Spacer(1, 0.3 * cm))
|
||||
idx = end_idx - 1 # Saltar las líneas de la tabla
|
||||
# Lista con guiones
|
||||
elif line.startswith("- ") or line.startswith("* "):
|
||||
text = self._escape_xml(line[2:])
|
||||
|
||||
@@ -200,6 +200,17 @@ class RemoteFolderWatcher:
|
||||
remote_path = f"{self.remote_path}/{filename}"
|
||||
local_path = self.local_path / filename
|
||||
|
||||
# Verificar si ya existe el archivo localmente
|
||||
if local_path.exists():
|
||||
# Verificar si ya fue procesado (existe transcripción con nombre EXACTO)
|
||||
stem = local_path.stem
|
||||
transcriptions_dir = self.local_path.parent / "transcriptions"
|
||||
txt_path = transcriptions_dir / f"{stem}.txt"
|
||||
|
||||
if txt_path.exists():
|
||||
self.logger.info(f"Skipping already processed file: {filename}")
|
||||
return
|
||||
|
||||
self.logger.info(f"Downloading: {remote_path}")
|
||||
|
||||
if self.webdav.download_file(remote_path, local_path):
|
||||
|
||||
Reference in New Issue
Block a user