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