fix: Mejoras en generación de PDFs y resúmenes

- Corrige PDFGenerator para pasar contenido (no ruta)
- Agrega prompt siguiendo código.md (español, estructura académica)
- Limpia thinking tokens de respuesta AI
- Agrega skip de archivos ya procesados en watcher
- Implementa tablas LaTeX en PDFs (reportlab Table)
- Agrega load_dotenv() en main.py
- Actualiza .env con MiniMax config
- Agrega transcriptions/ a .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renato97
2026-02-25 17:12:00 +00:00
parent ee8fc183be
commit 1f6bfa771b
5 changed files with 207 additions and 11 deletions

View File

@@ -504,7 +504,7 @@ class ProcessManager:
# Notificar resumen completado # Notificar resumen completado
telegram_service.send_summary_complete(filepath.name, has_markdown=True) 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 pdf_path = None
try: try:
from services.pdf_generator import PDFGenerator from services.pdf_generator import PDFGenerator
@@ -514,7 +514,9 @@ class ProcessManager:
pdf_generator = PDFGenerator() pdf_generator = PDFGenerator()
pdf_path = md_path.with_suffix(".pdf") 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( logger.info(
"PDF generado", "PDF generado",

36
main.py
View File

@@ -5,10 +5,15 @@ CBFacil - Sistema de transcripción de audio con IA y Notion
Características: Características:
- Polling de Nextcloud vía WebDAV - Polling de Nextcloud vía WebDAV
- Transcripción con Whisper (medium, GPU) - Transcripción con Whisper (medium, GPU)
- Resúmenes con IA (GLM-4.7) - Resúmenes con IA (MiniMax)
- Generación de PDF - Generación de PDF
- Notificaciones Telegram - Notificaciones Telegram
""" """
from dotenv import load_dotenv
# Cargar variables de entorno desde .env
load_dotenv()
import logging import logging
import os import os
import sys import sys
@@ -414,10 +419,31 @@ class PollingService:
# Extensiones de audio soportadas # Extensiones de audio soportadas
audio_extensions = {".mp3", ".wav", ".m4a", ".mp4", ".webm", ".ogg", ".flac"} audio_extensions = {".mp3", ".wav", ".m4a", ".mp4", ".webm", ".ogg", ".flac"}
pending_files = [ # Obtener transcripciones existentes para comparar
f for f in downloads_dir.iterdir() transcriptions_dir = settings.TRANSCRIPTIONS_DIR
if f.is_file() and f.suffix.lower() in audio_extensions and not f.name.startswith(".") processed_names = set()
] if transcriptions_dir.exists():
for f in transcriptions_dir.iterdir():
if f.is_file() and f.suffix == ".txt":
# Extraer nombre base sin extensión
processed_names.add(f.stem)
# 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 para este archivo
file_stem = f.stem
# También verificar versiones con " (copy)" o similar
if file_stem not in processed_names:
# Verificar variaciones del nombre
is_processed = False
for processed in processed_names:
if file_stem in processed or processed in file_stem:
is_processed = True
break
if not is_processed:
pending_files.append(f)
if not pending_files: if not pending_files:
logger.debug("No pending audio files to process") logger.debug("No pending audio files to process")

View File

@@ -69,8 +69,39 @@ class AISummaryService:
logger.debug("AISummaryService not configured, returning original text") logger.debug("AISummaryService not configured, returning original text")
return text return text
default_prompt = "Resume el siguiente texto de manera clara y concisa:" # Prompt siguiendo código.md - resumen académico en español
prompt = prompt_template.format(text=text) if prompt_template else f"{default_prompt}\n\n{text}" 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 = { payload = {
"model": self.model, "model": self.model,
@@ -96,6 +127,29 @@ class AISummaryService:
result = response.json() result = response.json()
summary = result.get("choices", [{}])[0].get("message", {}).get("content", "") 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)) logger.info("Summarization completed successfully (output length: %d)", len(summary))
return summary return summary

View File

@@ -5,13 +5,13 @@ Utiliza reportlab para la generación de PDFs con soporte UTF-8.
""" """
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Union from typing import Optional, Union
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm 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__) logger = logging.getLogger(__name__)
@@ -64,6 +64,92 @@ class PDFGenerator:
.replace("\n", "<br/>") .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:
# Procesar línea de tabla
# Reemplazar & por separador y eliminar \\
row_data = line.replace("&", "|").replace("\\", "").replace("\\\\", "")
# Limpiar formato LaTeX básico
row_data = row_data.replace("hline", "").replace("\\hline", "")
cells = [c.strip() for c in row_data.split("|") if c.strip()]
if cells:
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
cell = cell.replace("\\textbf{", "").replace("}", "")
cell = cell.replace("\\textit{", "")
cell = cell.replace("\\emph{", "")
cell = cell.strip()
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]: def _parse_markdown_basic(self, markdown: str) -> list[Paragraph]:
""" """
Convierte markdown básico a una lista de Paragraphs de reportlab. Convierte markdown básico a una lista de Paragraphs de reportlab.
@@ -101,6 +187,14 @@ class PDFGenerator:
# Línea horizontal # Línea horizontal
elif line == "---" or line == "***": elif line == "---" or line == "***":
elements.append(Spacer(1, 0.2 * cm)) 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 # Lista con guiones
elif line.startswith("- ") or line.startswith("* "): elif line.startswith("- ") or line.startswith("* "):
text = self._escape_xml(line[2:]) text = self._escape_xml(line[2:])

View File

@@ -200,6 +200,26 @@ class RemoteFolderWatcher:
remote_path = f"{self.remote_path}/{filename}" remote_path = f"{self.remote_path}/{filename}"
local_path = self.local_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)
stem = local_path.stem
transcriptions_dir = self.local_path.parent / "transcriptions"
processed = False
if transcriptions_dir.exists():
for f in transcriptions_dir.iterdir():
if f.suffix == ".txt" and stem in f.stem:
processed = True
break
if processed:
self.logger.info(f"Skipping already processed file: {filename}")
return
else:
# Existe pero no procesado, re-descargar
self.logger.info(f"Re-downloading incomplete file: {filename}")
self.logger.info(f"Downloading: {remote_path}") self.logger.info(f"Downloading: {remote_path}")
if self.webdav.download_file(remote_path, local_path): if self.webdav.download_file(remote_path, local_path):