Specs & Plans
Fase 31 — Exportador de hoja de estudio (PDF / DOCX / Anki / Markdown)
Fecha: 2026-05-30 Estado: Diseño aprobado (pendiente de implementación) Owner: Elias Tier: 4 (capas de UX / nicho) Tamaño: M (~3-4 días) Depende de: ninguna fase bloqueante. Reutiliza
AgentResult(todas las fases) y patrón SM-2 (Fase 14). Documento padre:2026-05-30-fases-22-32-overview.md
Motivación
Las 13 fases anteriores producen AgentResult con findings + citas verificables, pero el consumidor final muchas veces necesita un artefacto entregable (imprimible / repasable) en lugar del JSON:
- Una hoja de estudio en PDF para llevar a la reunión sin pantallas.
- Un DOCX para editar manualmente antes de imprimir o enviar.
- Un mazo Anki (
.apkg) para repaso espaciado de las conclusiones doctrinales. - Markdown para Obsidian / publicar / pegar en Notion.
Sin Fase 31, cada usuario re-implementa esta conversión en su flujo. Con Fase 31 cualquier AgentResult (apologetics, verse_explainer, research_topic, study_conductor, life_topics…) se convierte en uno de los cuatro formatos con una sola CLI o llamada MCP.
Objetivos (en orden de prioridad)
- IR única: una sola conversión
AgentResult → StudySheet. Todos los exporters consumenStudySheet, nuncaAgentResultdirectamente. - Markdown siempre disponible: sin extras, sin red, determinista — es la baseline mínima.
- PDF / DOCX / Anki opt-in vía
[pdf]/[docx]/[anki]extras. Cero hard dependency pesada. - Citas verificables preservadas: cada cita conserva URL + título + tipo. Tres modos de render: paréntesis inline, footnote, bibliografía.
- Plantillas pluggables: el usuario puede sobrescribir Jinja2 en
~/.jw-agent-toolkit/templates/. - Anki idempotente: re-export del mismo
StudySheetactualiza el note existente (mismoguid), no duplica.
No-objetivos (boundaries vinculantes)
- No generamos LLM prose nueva. El exporter solo formatea lo que ya viene en
findings[].summaryyfindings[].excerpt. - No descargamos imágenes de wol.jw.org. PDF/DOCX son texto + tipografía + estructura, no media.
- No firmamos PDFs ni añadimos DRM.
- No exportamos a EPUB / Kindle / HTML standalone (queda fuera de scope; PDF cubre imprimible).
- No subimos el
.apkga AnkiWeb. Generamos el archivo; el usuario lo importa. - No modificamos
AgentResultniFinding— Fase 31 es solo lectura.
Arquitectura
Nuevo módulo jw_core.exporters (parte de packages/jw-core, no paquete propio). Razón: depende solo de Pydantic + Jinja2 + (opcionales). No justifica un workspace member adicional.
packages/jw-core/src/jw_core/exporters/
├── __init__.py
├── ir.py # StudySheet (Pydantic) + from_agent_result()
├── markdown.py # MarkdownExporter — siempre disponible
├── pdf.py # PDFExporter — opt-in [pdf] (weasyprint + jinja2)
├── docx.py # DocxExporter — opt-in [docx] (python-docx)
└── anki.py # AnkiExporter — opt-in [anki] (genanki)
packages/jw-core/src/jw_core/templates/study_sheet/
├── plain.html.j2
└── study-sheet.html.j2
Y la integración:
packages/jw-cli/src/jw_cli/commands/export.py # jw export <json> --format pdf
packages/jw-mcp/src/jw_mcp/server.py # tool export_study_sheet(...)
Reglas duras de diseño
- Una sola conversión
AgentResult → StudySheet. Cada exporter recibeStudySheet, nuncaAgentResult. Razón: cada exporter solo decide cómo renderizar, no qué cosa renderizar. - Imports lazy:
weasyprint,python-docx,genankisolo se importan dentro de la función de exporter. Importarjw_core.exporterssin extras nunca falla. - Sin red en exporters. Si un finding lleva una URL, se cita; no se descarga.
- Cada exporter expone exactamente una función pública:
export_<format>(sheet, *, out, options) -> Path. - Plantillas resueltas vía
_resolve_template(name): primero~/.jw-agent-toolkit/templates/<name>, luegojw_core.templates.study_sheet.<name>empaquetado.
La IR — StudySheet
# packages/jw-core/src/jw_core/exporters/ir.py
from pydantic import BaseModel, Field
from typing import Literal, Any
CitationStyle = Literal["inline-paren", "footnote", "bibliography"]
class CitationIR(BaseModel):
"""Cita normalizada para todos los exporters."""
url: str
title: str = ""
kind: str = "" # 'verse' | 'article' | 'daily_text' | 'chapter'
short_label: str = "" # 'Juan 3:16' o 'w24/05 art.18'
metadata: dict[str, Any] = Field(default_factory=dict)
class StudySection(BaseModel):
"""Una sección de la hoja: heading + body + citas."""
heading: str
body: str # texto plano (markdown opcional en exporters)
excerpt: str = "" # cita literal del original (opcional)
citations: list[CitationIR] = Field(default_factory=list)
class StudySheet(BaseModel):
"""Documento intermedio. Todos los exporters lo consumen."""
title: str
subtitle: str = ""
language: str = "es" # 'en' | 'es' | 'pt'
sections: list[StudySection] = Field(default_factory=list)
footer_note: str = "" # ej. "Generado por jw-agent-toolkit"
metadata: dict[str, Any] = Field(default_factory=dict)
@classmethod
def from_agent_result(
cls,
result: "AgentResult | dict",
*,
title: str | None = None,
language: str = "es",
include_citations: bool = True,
) -> "StudySheet":
"""Único punto de conversión AgentResult → StudySheet."""
...
Reglas de conversión AgentResult → StudySheet
title=titlearg si se da, si noresult.metadata.get("title")si existe, si noresult.querytruncado a 80 chars.subtitle=result.agent_nameformateado humano (apologetics → "Análisis apologético").- Cada
Finding→ unStudySection:heading=finding.summary(primera línea truncada a 100 chars).body=finding.summarycompleto.excerpt=finding.excerptsi existe.citations=[finding.citation]mapeado aCitationIR(siinclude_citations).
result.warningsno entra como sección; va alfooter_notecon prefijo “Advertencias:”.- Si el
AgentResulttiene 0 findings →StudySheetcon 1 sección “(sin resultados)”.
Los cuatro exporters
1. Markdown — siempre disponible
export_markdown(sheet, *, out, citation_style="footnote") -> Path.
- Render determinista, sin dependencias externas.
- Tres estilos:
- inline-paren:
…texto del cuerpo (Juan 3:16, wol.jw.org/...). - footnote:
…texto del cuerpo[^1].+ footnotes al final. - bibliography: cuerpo limpio + lista numerada de fuentes al final.
- inline-paren:
- Cabecera incluye
# title+## subtitle+_idioma_. - Cada sección es
## heading+ cuerpo + (opcional) excerpt como blockquote.
2. PDF — opt-in [pdf]
export_pdf(sheet, *, out, theme="study-sheet", citation_style="footnote") -> Path.
- Implementación: Jinja2 renderiza
templates/study_sheet/<theme>.html.j2→ WeasyPrint convierte HTML a PDF. - Dos temas built-in:
plain: tipografía limpia (Inter / system serif), márgenes amplios.study-sheet: estilo cuaderno de estudio (Charter / Source Serif Pro, número de línea opcional, espacio para notas a la derecha).
- Citas con
citation_style:inline-paren:<sup>(<a href="…">Juan 3:16</a>)</sup>inline.footnote: numeradas, lista al final de cada sección o del documento.bibliography: bibliografía global al final del PDF.
- WeasyPrint debe estar instalada como extra; el módulo levanta
MissingDependencyErrorcon instrucciónpip install jw-core[pdf]si no está.
3. DOCX — opt-in [docx]
export_docx(sheet, *, out, citation_style="footnote") -> Path.
- Usa
python-docxdirectamente (no template Jinja2 — DOCX usa estructura programática). - Headings →
Heading 1(title) /Heading 2(section.heading) /Normal(body). - Excerpt →
Intense Quotestyle. - Footnotes vía
python-docxAPI (footnote endpoint). - Hyperlinks de citas insertadas como
add_hyperlink(...)helper.
4. Anki — opt-in [anki]
export_apkg(sheet, *, out, deck_name=None, per_citation_cards=False) -> Path.
- Implementación:
genanki.Deck+genanki.Note+genanki.Package. - Una nota por sección por defecto:
- Front:
section.heading. - Back:
section.body+ excerpt + lista de citas con URL clickable.
- Front:
- Si
per_citation_cards=Truey la sección tiene >1 cita: una nota extra por cita (front =citation.short_label, back =section.heading+ URL). - GUID estable:
sha256(sheet.title + section.heading + section.body[:200]). Re-export = update, no duplicate. deck_namedefault =sheet.title.model_idydeck_idderivados consha256del title (estables entre re-runs).
Resolución de plantillas
# en pdf.py
def _resolve_template(name: str) -> Path:
user_dir = Path.home() / ".jw-agent-toolkit" / "templates"
user_path = user_dir / name
if user_path.exists():
return user_path
return Path(__file__).parent.parent / "templates" / "study_sheet" / name
Esto cumple el principio de “plantillas pluggables sin tocar código del paquete”.
Modelo de errores
Una excepción única en jw_core.exporters:
class ExportError(Exception): ...
class MissingDependencyError(ExportError):
"""Se levanta cuando un extra opcional (weasyprint/python-docx/genanki) no está instalado."""
Cada exporter detecta su dep al inicio:
def export_pdf(...):
try:
import weasyprint
except ImportError as e:
raise MissingDependencyError(
"pip install 'jw-core[pdf]' to enable PDF export"
) from e
...
Integración
CLI (jw-cli)
jw export RESULT.json --format pdf --out hoja.pdf
jw export RESULT.json --format docx --out hoja.docx --citation-style bibliography
jw export RESULT.json --format apkg --out estudio.apkg --per-citation-cards
jw export RESULT.json --format markdown --out hoja.md --title "Trinidad — análisis"
RESULT.json es el AgentResult.to_dict() serializado. El CLI también acepta --from-stdin para pipelinear.
MCP (jw-mcp)
Nueva herramienta:
@app.tool()
def export_study_sheet(
agent_result: dict,
format: Literal["markdown", "pdf", "docx", "apkg"],
out_path: str,
title: str | None = None,
citation_style: Literal["inline-paren", "footnote", "bibliography"] = "footnote",
include_citations: bool = True,
theme: str = "study-sheet",
per_citation_cards: bool = False,
) -> dict:
"""Convierte un AgentResult en hoja de estudio (md/pdf/docx/apkg)."""
Retorna {"out": str, "format": str, "bytes_written": int} o {"error": "..."}.
Casos de uso reales
- Hermano que quiere estudiar Trinidad este sábado: ejecuta
jw apologetics "Trinidad" > result.json && jw export result.json --format pdf→ PDF impreso. - Precursora que quiere repasar pasajes apologéticos:
jw research-topic "alma humana" > result.json && jw export result.json --format apkg --per-citation-cards→ mazo Anki para repaso diario. - Anciano preparando discurso público:
jw meeting-helper "Romans 12:1" > result.json && jw export result.json --format docx→ DOCX para añadir notas personales antes de imprimir. - Investigador en Obsidian: pipeline MCP que llama agente +
export_study_sheet(format="markdown")y guarda en vault.
Riesgos y mitigaciones
| # | Riesgo | Mitigación |
|---|---|---|
| 1 | WeasyPrint requiere libs nativas (cairo, pango) que no compilan en todas las plataformas | Documentado como opt-in [pdf]. Markdown siempre funciona como fallback. CI no instala [pdf] por defecto |
| 2 | python-docx produce un XML específico que algunas versiones de Word no abren correctamente | Generamos con docx ≥ 1.1 (Office Open XML estándar). Tests validan que el archivo es ZIP válido y contiene word/document.xml |
| 3 | genanki cambia el modelo de cards entre versiones — los GUIDs viejos podrían no migrar | Pin genanki>=0.13,<1.0. GUID strategy es nuestra, no de genanki |
| 4 | Citas con URLs largas rompen layout en PDF | CSS word-wrap: break-word en plantillas. Test visual manual con URL muy larga |
| 5 | Caracteres no latinos (chino/coreano para ediciones futuras) → fuentes default no cubren | Plantilla declara unicode-range y usa stack con Noto Sans CJK fallback. Si la fuente falta el PDF renderiza tofu — documentado |
| 6 | Anki re-export con cambios menores genera GUID nuevo y duplica | GUID solo depende del heading + body[:200]. Cambios mayores son intencionales (nuevo card); cambios menores (typo en cite) se sobrescriben mediante import update |
| 7 | Inyección HTML maliciosa via finding.summary → XSS en PDF/DOCX | Jinja2 con autoescape=True por defecto. python-docx no interpreta HTML. Markdown escape básico para [, ], (, ) |
| 8 | Plantilla de usuario rota explota WeasyPrint | _resolve_template valida que el archivo existe y tiene extensión esperada. Errores Jinja2 se capturan y reempaquetan como ExportError con path y línea |
Métricas de éxito
jw export result.json --format markdowncorre en <100ms para unAgentResulttípico (5 findings).jw export result.json --format pdfcorre en <3s.- 1 ronda de import → revisar en Anki Desktop → re-export muestra “X notes updated, 0 added”.
- Markdown output válido para CommonMark (lint con
markdownlint). - DOCX abre correctamente en Word 365, LibreOffice 7+, Google Docs.
- PDF pasa
pdfinfosin warnings. - Documentado en
docs/guias/exportador-hoja-de-estudio.md. - Audit 1:1 en
docs/VISION_AUDIT.md(sección #11 “Exportador”).
Pendientes explícitos (post-Fase 31)
- Exportar a EPUB / Kindle — fase futura si surge demanda.
- Exportar diapositivas (PPTX) —
pptxskill ya existe; podría ser Fase 33. - Templates de comunidad / theme marketplace.
- Re-importar
.apkg→ reconstruirAgentResult(round-trip). No es objetivo de Fase 31.
Cómo verificar al cerrar
# 1. Instalar con todos los extras
uv sync --all-packages --all-extras
# 2. Generar un AgentResult de prueba
uv run jw apologetics "Trinidad" --json > /tmp/trinity.json
# 3. Markdown (siempre)
uv run jw export /tmp/trinity.json --format markdown --out /tmp/trinity.md
# 4. PDF (necesita [pdf])
uv run jw export /tmp/trinity.json --format pdf --out /tmp/trinity.pdf
# 5. DOCX (necesita [docx])
uv run jw export /tmp/trinity.json --format docx --out /tmp/trinity.docx
# 6. Anki (necesita [anki])
uv run jw export /tmp/trinity.json --format apkg --out /tmp/trinity.apkg
# 7. Tests del módulo
.venv/bin/python -m pytest packages/jw-core/tests/test_exporter_*.py -v
Plan de implementación
Spec hijo: docs/superpowers/plans/2026-05-30-fase-31-exporter-plan.md.
Pasos cronológicos (resumidos — ver plan):
- IR
StudySheet+from_agent_resultcon tests. - Markdown exporter (3 estilos de cita) con tests.
- Plantillas Jinja2
plainystudy-sheet. - PDF exporter con WeasyPrint + skip-if-missing en tests.
- DOCX exporter con python-docx + skip-if-missing.
- Anki exporter con genanki + GUID estable + skip-if-missing.
- Resolución de templates de usuario.
- CLI
jw export. - MCP tool
export_study_sheet. - Guía + audit.
Cada paso con su PR + tests verdes + sin regresión.
Edit this page on docs/superpowers/specs/2026-05-30-fase-31-exporter-design.md