Files
fn_registry/python/functions/datascience/render_paper_pdf.py
T
egutierrez 9c1b7dd0f3 feat(papers): render_paper_pdf (Markdown IMRaD → PDF) + agente paper-reviewer
Subsistema papers/: pieza de entrega + control de calidad.

- render_paper_pdf_py_datascience (Python, impure, dominio datascience, grupo
  `papers`): convierte papers/<slug>/paper.md (frontmatter YAML + cuerpo IMRaD)
  en papers/<slug>/out/paper.pdf. Reutiliza el motor de paginación de flujo del
  paquete automatic_eda (matplotlib PdfPages, el mismo PDF móvil A5 de los
  informes EDA) — no reimplementa paginación ni toca matplotlib, y no añade
  dependencias. Cada sección IMRaD (# H1) → un Chapter en página nueva; portada
  desde el frontmatter (title/authors/date europea/abstract); detecta las
  imágenes Markdown ![alt](src) que el motor no entiende y las parte en bloques
  Image resueltos contra base_dir y base_dir/figures/. dict-no-throw estricto.
  5 tests verdes (golden + edges: sin frontmatter, path inexistente, figura
  inexistente, ruta directa al .md).

- .claude/agents/paper-reviewer: revisor académico adversarial read-only (gate
  anti paper-mill). Puntúa novedad/rigor/reproducibilidad/validez (0-5), intenta
  refutar cada claim contra la evidencia citada, detecta HARKing contra el
  preregistration.md, exige limitaciones declaradas y claims ≤ evidencia, y
  emite veredicto estructurado JSON (accept|major_revision|reject) con default
  conservador. Tools: Read, Grep, Glob, Bash (sin Edit/Write: solo juzga).

Diseño completo: reports/0001-2026-06-30-papers-system-design.md (agente C).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:39:59 +02:00

298 lines
11 KiB
Python

"""render_paper_pdf — convierte un paper académico IMRaD en Markdown a un PDF.
Toma un paper escrito en Markdown con frontmatter YAML opcional (título,
autores, fecha, abstract) más un cuerpo dividido en secciones IMRaD por
encabezados de nivel 1 (``# Introduction``, ``# Methods``, ...) y produce un PDF
``out/paper.pdf`` junto al paper.
REUTILIZA el paginador de flujo del paquete ``automatic_eda`` (el mismo motor
que rinde los informes EDA en PDF móvil A5): no reimplementa paginación ni toca
matplotlib directamente. Cada sección IMRaD se mapea a un ``Chapter`` (empieza
en página nueva). El motor ``_place_markdown`` parsea por sí mismo headings,
listas, tablas pipe, párrafos y ``**negrita**`` dentro del texto, pero NO
entiende la sintaxis de imagen Markdown ``![alt](src)``; por eso esta función
detecta esas líneas y las convierte en bloques ``Image`` separados, partiendo el
texto Markdown alrededor de cada imagen.
dict-no-throw (estilo del grupo eda): NUNCA lanza. Devuelve
``{status, pdf_path, n_pages, note}``; ante cualquier fallo devuelve
``status="error"`` con ``pdf_path=None`` y la causa en ``note``.
"""
from __future__ import annotations
import datetime as _dt
import os
import re
from datascience.automatic_eda import Chapter, Heading, Image, Markdown, render_pdf
# Una línea cuyo único contenido es una imagen Markdown: ![alt](src)
_IMG_LINE = re.compile(r"^\s*!\[([^\]]*)\]\(\s*([^)\s]+)\s*\)\s*$")
# Un encabezado de nivel 1 al inicio de línea (un solo '#' seguido de espacio).
_H1_LINE = re.compile(r"^#[ \t]+(.+?)\s*$")
def render_paper_pdf(paper_dir: str) -> dict:
"""Renderiza un paper académico Markdown IMRaD en un PDF.
Args:
paper_dir: ruta al directorio del paper (``papers/<slug>/``, del que se
lee ``paper.md``) o directamente la ruta a un archivo ``paper.md``.
Returns:
dict (nunca lanza): ``{status: "ok"|"error", pdf_path: str|None,
n_pages: int, note: str}``. En éxito ``pdf_path`` es la ruta escrita y
``n_pages`` el total de páginas; en error ``pdf_path`` es None y
``note`` explica la causa.
"""
try:
# 1) Resolver el path del paper.md y el directorio base.
arg = str(paper_dir)
md_path = arg if arg.endswith(".md") else os.path.join(arg, "paper.md")
# 2) Si el paper.md no existe, degradar sin crash.
if not os.path.isfile(md_path):
return {"status": "error", "pdf_path": None, "n_pages": 0,
"note": f"paper.md no encontrado: {md_path}"}
base_dir = os.path.dirname(os.path.abspath(md_path))
# 3) Leer el archivo y separar frontmatter del cuerpo.
with open(md_path, "r", encoding="utf-8") as fh:
text = fh.read()
fm_text, body = _split_frontmatter(text)
fm = _parse_frontmatter(fm_text)
title = _safe_str(fm.get("title")).strip()
authors = fm.get("authors")
date_raw = fm.get("date")
abstract = _safe_str(fm.get("abstract")).strip()
# 4) Construir los capítulos: portada (si hay título) + cuerpo IMRaD.
chapters: list = []
if title:
cover_md = _portada_markdown(authors, date_raw, abstract)
cover_blocks: list = [Heading(text=title, level=1)]
if cover_md.strip():
cover_blocks.append(Markdown(text=cover_md))
chapters.append(Chapter(id="portada", title=title, version="1.0.0",
blocks=cover_blocks))
preamble, sections = _split_body_sections(body)
if not sections:
# Sin encabezados H1: todo el cuerpo en un único capítulo.
chapters.append(Chapter(
id="cuerpo", title="Cuerpo", version="1.0.0",
blocks=_markdown_to_blocks(body, base_dir)))
else:
# Texto antes del primer H1 (si lo hay) como capítulo previo.
if preamble.strip():
chapters.append(Chapter(
id="cuerpo", title="Cuerpo", version="1.0.0",
blocks=_markdown_to_blocks(preamble, base_dir)))
for idx, (sec_title, sec_body) in enumerate(sections):
blocks: list = [Heading(text=sec_title, level=1)]
blocks.extend(_markdown_to_blocks(sec_body, base_dir))
chapters.append(Chapter(
id=_slugify(sec_title) or f"sec{idx}",
title=sec_title, version="1.0.0", blocks=blocks))
# 5) Renderizar con el motor de automatic_eda.
out_path = os.path.join(base_dir, "out", "paper.pdf")
res = render_pdf(chapters, out_path, meta={"title": title or "paper"})
# 6) Mapear el retorno del motor a la forma de esta función.
path = res.get("path")
return {
"status": "ok" if path else "error",
"pdf_path": path,
"n_pages": int(res.get("n_pages") or 0),
"note": res.get("note"),
}
except Exception as e: # noqa: BLE001 — dict-no-throw estricto.
return {"status": "error", "pdf_path": None, "n_pages": 0,
"note": f"fallo: {e}"}
# --------------------------------------------------------------------------- #
# Frontmatter
# --------------------------------------------------------------------------- #
def _split_frontmatter(text: str):
"""Separa el bloque frontmatter YAML inicial del cuerpo.
Devuelve ``(fm_text|None, body)``. Si el archivo no empieza con una valla
``---`` o no se cierra, no hay frontmatter y el cuerpo es el texto entero.
"""
if text.startswith(""):
text = text.lstrip("")
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return None, text
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return "\n".join(lines[1:i]), "\n".join(lines[i + 1:])
# Valla de apertura sin cierre: tratar todo como cuerpo.
return None, text
def _parse_frontmatter(fm_text) -> dict:
"""Parsea el frontmatter. Intenta YAML; si no, parser line-based simple."""
if not fm_text:
return {}
try:
import yaml # type: ignore
data = yaml.safe_load(fm_text)
if isinstance(data, dict):
return data
except Exception: # noqa: BLE001 — yaml ausente o frontmatter inválido.
pass
# Fallback degradado: 'clave: valor' por línea.
out: dict = {}
for line in fm_text.split("\n"):
stripped = line.strip()
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
k, _, v = stripped.partition(":")
k = k.strip()
v = v.strip().strip('"').strip("'")
if k:
out[k] = v
return out
# --------------------------------------------------------------------------- #
# Portada
# --------------------------------------------------------------------------- #
def _portada_markdown(authors, date_raw, abstract) -> str:
"""Markdown de la portada: autores, fecha y, si hay, el abstract."""
parts: list = []
authors_str = _fmt_authors(authors)
if authors_str:
parts.append(f"**Autores:** {authors_str}")
if date_raw not in (None, ""):
parts.append(f"**Fecha:** {_fmt_date(date_raw)}")
md = "\n\n".join(parts)
abstract = _safe_str(abstract).strip()
if abstract:
md = (md + "\n\n" if md else "") + "## Abstract\n\n" + abstract
return md
def _fmt_authors(authors) -> str:
"""Lista o string de autores → string separado por comas."""
if authors in (None, ""):
return ""
if isinstance(authors, (list, tuple)):
return ", ".join(_safe_str(a).strip() for a in authors
if _safe_str(a).strip())
return _safe_str(authors).strip()
def _fmt_date(raw) -> str:
"""Fecha → ``DD/MM/AAAA`` si es parseable; si no, el valor crudo."""
if isinstance(raw, _dt.datetime):
return raw.strftime("%d/%m/%Y")
if isinstance(raw, _dt.date):
return raw.strftime("%d/%m/%Y")
s = _safe_str(raw).strip()
if not s:
return s
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"):
try:
return _dt.datetime.strptime(s, fmt).strftime("%d/%m/%Y")
except ValueError:
continue
try:
return _dt.datetime.fromisoformat(s).strftime("%d/%m/%Y")
except Exception: # noqa: BLE001
return s
# --------------------------------------------------------------------------- #
# Cuerpo y figuras
# --------------------------------------------------------------------------- #
def _split_body_sections(body: str):
"""Divide el cuerpo en (preámbulo, [(título_H1, contenido)...]) por H1."""
preamble_lines: list = []
sections: list = []
current = None # (titulo, [lineas])
for line in body.split("\n"):
m = _H1_LINE.match(line)
if m and not line.startswith("##"):
if current is not None:
sections.append((current[0], "\n".join(current[1])))
current = (m.group(1).strip(), [])
elif current is None:
preamble_lines.append(line)
else:
current[1].append(line)
if current is not None:
sections.append((current[0], "\n".join(current[1])))
return "\n".join(preamble_lines), sections
def _markdown_to_blocks(text: str, base_dir: str) -> list:
"""Parte un Markdown en bloques Markdown/Image alrededor de cada figura.
Las líneas ``![alt](src)`` con ``src`` local se convierten en ``Image``; las
que apuntan a URLs http(s) se dejan como texto Markdown.
"""
blocks: list = []
buf: list = []
def _flush():
chunk = "\n".join(buf).strip("\n")
if chunk.strip():
blocks.append(Markdown(text=chunk))
buf.clear()
for line in text.split("\n"):
m = _IMG_LINE.match(line)
if m:
alt, src = m.group(1), m.group(2)
if src.lower().startswith(("http://", "https://")):
buf.append(line) # URL remota: se mantiene como texto.
continue
_flush()
blocks.append(Image(path=_resolve_src(src, base_dir),
caption=(alt or None)))
else:
buf.append(line)
_flush()
return blocks
def _resolve_src(src: str, base_dir: str) -> str:
"""Resuelve la ruta de una figura relativa al paper.
Absoluta → tal cual. Relativa → prueba ``base_dir/src`` y
``base_dir/figures/<basename>``; usa la primera que exista, o el join con
``base_dir`` si ninguna (el motor degrada dibujando el aviso de no-encontrada).
"""
if os.path.isabs(src):
return src
cand1 = os.path.join(base_dir, src)
cand2 = os.path.join(base_dir, "figures", os.path.basename(src))
for c in (cand1, cand2):
if os.path.exists(c):
return c
return cand1
def _slugify(text: str) -> str:
"""Slug ASCII corto para el id del capítulo."""
s = re.sub(r"[^a-z0-9]+", "_", _safe_str(text).lower()).strip("_")
return s[:40]
def _safe_str(v) -> str:
"""str() que nunca lanza y mapea None a ''."""
if v is None:
return ""
try:
return str(v)
except Exception: # noqa: BLE001
return ""