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:
@@ -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",
|
||||
|
||||
36
main.py
36
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")
|
||||
|
||||
@@ -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,92 @@ 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:
|
||||
# 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:])
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user