From 1f6bfa771b578b8a02c988ba03a84e38ec873300 Mon Sep 17 00:00:00 2001 From: renato97 Date: Wed, 25 Feb 2026 17:12:00 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20Mejoras=20en=20generaci=C3=B3n=20de=20PD?= =?UTF-8?q?Fs=20y=20res=C3=BAmenes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- core/process_manager.py | 6 ++- main.py | 36 +++++++++++-- services/ai_summary_service.py | 58 +++++++++++++++++++- services/pdf_generator.py | 98 +++++++++++++++++++++++++++++++++- watchers/folder_watcher.py | 20 +++++++ 5 files changed, 207 insertions(+), 11 deletions(-) diff --git a/core/process_manager.py b/core/process_manager.py index 51773c8..b245f73 100644 --- a/core/process_manager.py +++ b/core/process_manager.py @@ -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", diff --git a/main.py b/main.py index 2c9945a..a0321a1 100644 --- a/main.py +++ b/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 @@ -414,10 +419,31 @@ 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 para comparar + transcriptions_dir = settings.TRANSCRIPTIONS_DIR + 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: logger.debug("No pending audio files to process") diff --git a/services/ai_summary_service.py b/services/ai_summary_service.py index 10bab40..2a70ed3 100644 --- a/services/ai_summary_service.py +++ b/services/ai_summary_service.py @@ -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("") or line.strip().endswith(""): + 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 diff --git a/services/pdf_generator.py b/services/pdf_generator.py index aeb01a0..18bea6b 100644 --- a/services/pdf_generator.py +++ b/services/pdf_generator.py @@ -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,92 @@ class PDFGenerator: .replace("\n", "
") ) + 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]: """ Convierte markdown básico a una lista de Paragraphs de reportlab. @@ -101,6 +187,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:]) diff --git a/watchers/folder_watcher.py b/watchers/folder_watcher.py index f0ac646..eec143b 100644 --- a/watchers/folder_watcher.py +++ b/watchers/folder_watcher.py @@ -200,6 +200,26 @@ 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) + 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}") if self.webdav.download_file(remote_path, local_path):