typst-ia.py
· 19 KiB · Python
Eredeti
#!/usr/bin/env python3
"""
typst-ia: Genera presentaciones Typst con IA vía OpenRouter.
Uso:
typst-ia "Tema de la presentación"
typst-ia "Historia de Linux" --slides 10
typst-ia "Comandos Git" --slides 8 --model "anthropic/claude-3.5-sonnet"
typst-ia "Python async" --slides 10 --output python-async
Dependencias:
pip install requests
Requiere Typst instalado:
sudo apt install typst # Debian/Ubuntu
sudo pacman -S typst # Arch
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
import requests
# ── Constantes ──────────────────────────────────────────────────────────────
API_KEY = os.environ.get("OPENROUTER_API_KEY")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "deepseek/deepseek-chat"
# Directorio donde está este script
SCRIPT_DIR = Path(__file__).resolve().parent
TEMPLATE_FILE = SCRIPT_DIR / "template" / "slydst.typ"
OUTPUT_DIR = SCRIPT_DIR
# ── Funciones auxiliares ────────────────────────────────────────────────────
def leer_template():
"""Lee el template slydst.typ del directorio template/"""
if not TEMPLATE_FILE.exists():
raise FileNotFoundError(
f"No se encuentra el template en {TEMPLATE_FILE}\n"
f"Asegúrate de que existe template/slydst.typ junto al script"
)
return TEMPLATE_FILE.read_text()
def escapar_typst_string(texto):
"""Escapa caracteres especiales en strings Typst (comillas, saltos de línea)"""
return texto.replace('"', '\\"').replace('\n', ' ')
def construir_system_prompt(tema, n_slides, template_content):
"""
Construye el array de mensajes para OpenRouter.
El system prompt incluye el template real para que la IA conozca
las capacidades de la plantilla.
"""
return [
{
"role": "system",
"content": (
"Eres un experto en crear presentaciones con Typst.\n"
"Tu tarea es generar el contenido de una presentación en formato Typst.\n\n"
"ESTRUCTURA:\n"
"- `= Título` → heading level 1 (portada automática)\n"
"- `== Título` → heading level 2 (nueva página, slide de contenido)\n"
"- Listas con `- ` (bullet) o `+ ` (numerada)\n"
"- Código con ```lenguaje ... ``` (tres backticks)\n"
"- Tablas con #table(columns: ..., ...)\n"
"- Imágenes con #image(\"images/placeholder.png\", width: 70%)\n"
"- Dos columnas con #columns(2, gutter: 12pt)[ ... #colbreak() ... ]\n"
"- Énfasis con _cursiva_ (guión bajo). NO uses * para nada — ni énfasis, ni comodines, ni wildcards (como `test_*`).\n\n"
"REGLAS ESTRICTAS:\n"
"1. NO incluyas '#import' — el script lo añade automáticamente\n"
"2. NO incluyas '#show: slides.with(...)' — el script lo añade\n"
"3. El primer heading DEBE ser `= ` (portada del tema) — la respuesta "
"debe EMPEZAR directamente con `= `, sin texto ni comentarios previos\n"
"4. Usa SIEMPRE `==` para slides de contenido\n"
"5. NO uses caracteres como $, \\, # fuera de contexto Typst\n"
"6. No envuelvas el código en bloques markdown (```)\n"
"7. Máximo 6-8 bullet points por slide\n"
"8. Incluye variedad: bullets, código, tablas según el tema\n"
"9. NO uses NUNCA el carácter `*` en ningún contexto — ni para énfasis, ni para comodines (como `test_*`). En Typst `*` abre énfasis/itálica y rompe la compilación. Usa `_cursiva_` para énfasis y `- ` para viñetas.\n"
"TEMPLATE DISPONIBLE:\n"
f"{template_content}\n\n"
f"Genera exactamente {n_slides} slides para el tema \"{tema}\".\n"
"Incluye al menos un bloque de código y una tabla si el tema lo permite."
),
},
{
"role": "user",
"content": f"Tema: {tema}\nNúmero de slides: {n_slides}",
},
]
def llamar_openrouter(messages, model):
"""
Llama a OpenRouter API con reintentos ante rate limit, timeout o error de conexión.
Devuelve el texto generado por la IA.
"""
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://atareao.es",
"X-OpenRouter-Title": "typst-ia",
}
for intento in range(3):
try:
resp = requests.post(
API_URL,
headers=headers,
json={
"model": model,
"messages": messages,
"temperature": 0.7,
"max_tokens": 4096,
},
timeout=120,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except requests.exceptions.HTTPError as e:
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", 10))
print(f" ⏳ Rate limit. Esperando {wait}s...")
time.sleep(wait)
continue
elif resp.status_code == 401:
sys.exit("❌ API Key inválida. Configura OPENROUTER_API_KEY")
elif resp.status_code == 402:
sys.exit("❌ Saldo insuficiente en OpenRouter. Añade créditos en openrouter.ai")
else:
sys.exit(f"❌ Error HTTP {resp.status_code}: {e}")
except requests.exceptions.Timeout:
if intento < 2:
print(f" ⏳ Timeout. Reintentando ({intento + 1}/3)...")
time.sleep(2 ** intento)
continue
sys.exit("❌ Timeout tras 3 intentos. Prueba otro modelo o más tarde.")
except requests.exceptions.ConnectionError:
if intento < 2:
print(f" ⚠️ Error de conexión. Reintentando ({intento + 1}/3)...")
time.sleep(2 ** intento)
continue
sys.exit("❌ Error de conexión con OpenRouter. Revisa tu conexión a Internet.")
def normalizar_listas(linea):
"""
Convierte una línea de lista con `*` (que Typst interpreta como énfasis/itálica)
en `-` (viñeta correcta de Typst).
Solo afecta a líneas donde el asterisco va seguido de espacio o está al final
de la línea (lista), NO a *texto* inline ni a **negrita** (estilo Markdown).
"""
m = re.match(r'^(\s*)\*($|\s)', linea)
if m and not linea.strip().startswith('**'):
prefix = m.group(1)
if m.group(2) == ' ':
# * seguido de espacio: mantener el espacio
return prefix + '- ' + linea[m.end():]
# * al final de línea
return prefix + '-'
return linea
def convertir_negrita(linea):
"""
Convierte **texto** (estilo Markdown) en _texto_ (strong de Typst).
En Typst ** no es válido y genera el warning 'no text within stars'.
"""
return re.sub(r'\*\*([^*\n]+)\*\*', r'_\1_', linea)
def escapar_asteriscos(linea):
"""
Escapa asteriscos sueltos de una línea (fuera de bloques de código)
que Typst interpretaría como énfasis y romperían la compilación.
Convierte * a \\* para que Typst los muestre literalmente.
Es un safety net para cuando la IA ignora la regla de no usar *.
"""
# No tocar headings (= o ==): ahí * se usa para énfasis válido
if linea.strip().startswith('='):
return linea
# No tocar asteriscos ya escapados
return re.sub(r'(?<!\\)\*', '\\\\*', linea)
def limpiar_respuesta(texto):
"""
Limpia la respuesta de la IA:
- Conserva bloques de código ```lang ... ``` como raw blocks Typst válidos
(y los cierra si la IA los dejó abiertos)
- Quita líneas de #import o #show: slides.with (fuera de código)
- Fuera de código: normaliza listas (* → -), convierte **x** → _x_,
y escapa asteriscos sueltos
"""
# ── Pasada 1: separar bloques de código, quitar imports ──
lineas = []
en_codigo = False
for linea in texto.split('\n'):
stripped = linea.strip()
# Fence de apertura o cierre de bloque de código
if stripped.startswith('```'):
en_codigo = not en_codigo
lineas.append(linea)
continue
if en_codigo:
# Contenido de código: se conserva tal cual
lineas.append(linea)
continue
if stripped.startswith('#import') or stripped.startswith('#show: slides.with'):
continue
lineas.append(linea)
texto = '\n'.join(lineas)
# Si quedó un bloque sin cerrar, cerrarlo (evita 'unclosed raw text')
if en_codigo:
texto += '\n```'
# ── Pasada 2: procesar solo líneas fuera de bloques de código ──
resultado = []
en_codigo = False
for linea in texto.split('\n'):
stripped = linea.strip()
if stripped.startswith('```'):
en_codigo = not en_codigo
resultado.append(linea)
continue
if en_codigo:
resultado.append(linea)
continue
# Fuera de código: normalizar, negrita y escapar asteriscos
linea = normalizar_listas(linea)
linea = convertir_negrita(linea)
linea = escapar_asteriscos(linea)
resultado.append(linea)
return '\n'.join(resultado)
def generar_archivo_typ(tema, contenido_typ, output_path, background="fondo-matrix-hd.jpg"):
"""
Genera el archivo .typ completo con:
- Import del template
- Show rule con slides.with()
- Configuración visual (raw, text, heading)
- Contenido generado por la IA
"""
with open(output_path, "w") as f:
f.write('#import "template/slydst.typ": *\n')
f.write('\n')
f.write('#show: slides.with(\n')
f.write(f' title: "{escapar_typst_string(tema)}",\n')
f.write(' subtitle: "Generado con IA",\n')
f.write(f' comment: "Generado con typst-ia",\n')
f.write(f' authors: "Lorenzo Carbonell <a.k.a atareao>",\n')
f.write(' layout: "large",\n')
f.write(' ratio: 16 / 9,\n')
f.write(f' background: "{background}",\n')
f.write(')\n')
f.write('\n')
f.write('#show raw: set block(fill: rgb("1d2433"), width: 100%, inset: 0.6em)\n')
f.write('#set text(size: 10pt)\n')
f.write('#show heading: set text(size: 11pt)\n')
f.write('\n')
f.write(contenido_typ)
f.write('\n')
print(f" 📄 Archivo .typ guardado: {output_path}")
def compilar_typst(typ_path, pdf_path):
"""Compila .typ a PDF con typst compile. Devuelve True si éxito."""
result = subprocess.run(
["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)],
capture_output=True,
text=True,
cwd=typ_path.parent,
)
if result.returncode != 0:
print(f" ❌ Error de compilación Typst:")
print(f" {result.stderr[:600]}")
return False
print(f" ✅ PDF generado: {pdf_path}")
return True
def compilar_con_reintentos(typ_path, pdf_path, messages, model, max_intentos=2):
"""
Compila y si falla, pide a la IA que corrija el error.
Útil porque la IA no siempre genera Typst 100% válido a la primera.
A diferencia de la versión anterior, cada intento usa mensajes de corrección
FRESCOS para evitar acumulación de correcciones anteriores.
"""
for intento in range(max_intentos + 1):
contenido_actual = typ_path.read_text()
result = subprocess.run(
["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)],
capture_output=True,
text=True,
cwd=typ_path.parent,
)
if result.returncode == 0:
print(f" ✅ PDF generado: {pdf_path}")
return True
if intento == max_intentos:
print(f" ❌ Error tras {max_intentos} correcciones:")
print(f" {result.stderr[:500]}")
return False
error_msg = result.stderr[:1000]
print(f" ⚠️ Error de compilación. Pidiendo corrección (intento {intento + 1}/{max_intentos})...")
# Crear mensajes de corrección FRESCOS por intento (evitar acumulación)
correccion_msgs = list(messages) + [
{"role": "assistant", "content": contenido_actual},
{
"role": "user",
"content": (
f"El código Typst tiene este error de compilación:\n"
f"{error_msg}\n\n"
"Corrige SOLO el error. Devuelve TODO el contenido corregido, "
"sin el #import ni el #show, solo el contenido de las slides."
),
},
]
contenido_corregido = llamar_openrouter(correccion_msgs, model)
if contenido_corregido is None:
print(" ❌ La IA no devolvió una corrección válida.")
return False
contenido_corregido = limpiar_respuesta(contenido_corregido)
# Reescribir el .typ: preámbulo original + contenido corregido
preambulo = ""
for linea in contenido_actual.split("\n"):
preambulo += linea + "\n"
if linea.strip().startswith("#show heading:"):
break
typ_path.write_text(preambulo + contenido_corregido + "\n")
return False
def abrir_pdf(pdf_path):
"""Abre el PDF con el visor por defecto (zathura > xdg-open)"""
if pdf_path.exists():
# Intentar zathura primero
if subprocess.run(["which", "zathura"], capture_output=True).returncode == 0:
subprocess.Popen(["zathura", str(pdf_path)])
return
# Fallback a xdg-open
subprocess.run(["xdg-open", str(pdf_path)], check=False)
# ── Punto de entrada ────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="typst-ia: Genera presentaciones Typst con IA",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Ejemplos:
typst-ia "Historia de Linux"
typst-ia "Comandos Git" --slides 8
typst-ia "Arquitectura de contenedores" --slides 12 --model "anthropic/claude-3.5-sonnet"
typst-ia "Python async/await" --slides 10 --output python-async
""",
)
parser.add_argument("tema", help="Tema de la presentación (entre comillas si tiene espacios)")
parser.add_argument(
"--slides", type=int, default=10,
help="Número de slides a generar (default: 10)"
)
parser.add_argument(
"--model", default=DEFAULT_MODEL,
help=f"Modelo en OpenRouter (default: {DEFAULT_MODEL})"
)
parser.add_argument(
"--output", "-o",
help="Nombre del archivo de salida (sin extensión). Por defecto: slug del tema"
)
parser.add_argument(
"--retries", type=int, default=0,
help="Intentos de corrección automática si falla la compilación (default: 0)"
)
parser.add_argument(
"--no-open", action="store_true",
help="No abrir el PDF automáticamente al terminar"
)
parser.add_argument(
"--background", default="fondo-matrix-hd.jpg",
help="Imagen de fondo en images/ (default: fondo-matrix-hd.jpg)"
)
args = parser.parse_args()
# ── Validaciones ────────────────────────────────────────────────────
if not API_KEY:
sys.exit(
"❌ Configura OPENROUTER_API_KEY como variable de entorno\n"
" export OPENROUTER_API_KEY=\"sk-or-v1-tu-api-key-aqui\""
)
# ── Slug para nombres de archivo ────────────────────────────────────
if args.output:
slug = args.output
else:
slug = re.sub(r'[^a-z0-9]+', '-', args.tema.lower()).strip('-')[:50]
typ_path = OUTPUT_DIR / f"{slug}.typ"
pdf_path = OUTPUT_DIR / f"{slug}.pdf"
# ── Inicio ──────────────────────────────────────────────────────────
print(f"🚀 typst-ia — Generando presentación")
print(f" Tema: \"{args.tema}\"")
print(f" Slides: {args.slides}")
print(f" Modelo: {args.model}")
print()
# 1. Leer el template
print("📖 Leyendo template...")
try:
template = leer_template()
except FileNotFoundError as e:
sys.exit(f"❌ {e}")
print(f" Template: {TEMPLATE_FILE.name} ({len(template)} caracteres)")
print()
# 2. Construir prompt y llamar a OpenRouter
print(f"🤖 Llamando a OpenRouter ({args.model})...")
print(" (esto puede tardar unos segundos)")
messages = construir_system_prompt(args.tema, args.slides, template)
sys.stdout.flush()
contenido = llamar_openrouter(messages, args.model)
if contenido is None:
sys.exit("❌ No se pudo generar contenido tras varios intentos.")
contenido = limpiar_respuesta(contenido)
print(f" Recibidos ~{len(contenido.split())} tokens / ~{len(contenido.split(chr(10)))} líneas")
print()
# 3. Guardar archivo .typ
print("📝 Generando archivo Typst...")
generar_archivo_typ(args.tema, contenido, typ_path, background=args.background)
print()
# 4. Compilar a PDF
print("⚙️ Compilando a PDF...")
if args.retries > 0:
exito = compilar_con_reintentos(typ_path, pdf_path, messages, args.model, args.retries)
else:
exito = compilar_typst(typ_path, pdf_path)
if not exito:
print()
print("🔍 Contenido generado (primeros 600 caracteres):")
print(" " + "-" * 50)
print(contenido[:600])
print(" " + "-" * 50)
print()
print("💡 Sugerencias:")
print(" - Revisa el archivo .typ y corrige errores manualmente")
print(" - Ejecuta: typst compile --root . " + str(typ_path) + " " + str(pdf_path))
print(" - Usa --retries 2 para que la IA intente corregir automáticamente")
sys.exit(1)
print()
# 5. Abrir PDF
if not args.no_open:
print("📂 Abriendo presentación...")
abrir_pdf(pdf_path)
print(f"✨ ¡Listo! Presentación generada: {pdf_path}")
if __name__ == "__main__":
main()
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | typst-ia: Genera presentaciones Typst con IA vía OpenRouter. |
| 4 | |
| 5 | Uso: |
| 6 | typst-ia "Tema de la presentación" |
| 7 | typst-ia "Historia de Linux" --slides 10 |
| 8 | typst-ia "Comandos Git" --slides 8 --model "anthropic/claude-3.5-sonnet" |
| 9 | typst-ia "Python async" --slides 10 --output python-async |
| 10 | |
| 11 | Dependencias: |
| 12 | pip install requests |
| 13 | |
| 14 | Requiere Typst instalado: |
| 15 | sudo apt install typst # Debian/Ubuntu |
| 16 | sudo pacman -S typst # Arch |
| 17 | """ |
| 18 | |
| 19 | import argparse |
| 20 | import json |
| 21 | import os |
| 22 | import re |
| 23 | import subprocess |
| 24 | import sys |
| 25 | import time |
| 26 | from pathlib import Path |
| 27 | |
| 28 | import requests |
| 29 | |
| 30 | |
| 31 | # ── Constantes ────────────────────────────────────────────────────────────── |
| 32 | |
| 33 | API_KEY = os.environ.get("OPENROUTER_API_KEY") |
| 34 | API_URL = "https://openrouter.ai/api/v1/chat/completions" |
| 35 | DEFAULT_MODEL = "deepseek/deepseek-chat" |
| 36 | |
| 37 | # Directorio donde está este script |
| 38 | SCRIPT_DIR = Path(__file__).resolve().parent |
| 39 | TEMPLATE_FILE = SCRIPT_DIR / "template" / "slydst.typ" |
| 40 | OUTPUT_DIR = SCRIPT_DIR |
| 41 | |
| 42 | |
| 43 | # ── Funciones auxiliares ──────────────────────────────────────────────────── |
| 44 | |
| 45 | def leer_template(): |
| 46 | """Lee el template slydst.typ del directorio template/""" |
| 47 | if not TEMPLATE_FILE.exists(): |
| 48 | raise FileNotFoundError( |
| 49 | f"No se encuentra el template en {TEMPLATE_FILE}\n" |
| 50 | f"Asegúrate de que existe template/slydst.typ junto al script" |
| 51 | ) |
| 52 | return TEMPLATE_FILE.read_text() |
| 53 | |
| 54 | |
| 55 | def escapar_typst_string(texto): |
| 56 | """Escapa caracteres especiales en strings Typst (comillas, saltos de línea)""" |
| 57 | return texto.replace('"', '\\"').replace('\n', ' ') |
| 58 | |
| 59 | |
| 60 | def construir_system_prompt(tema, n_slides, template_content): |
| 61 | """ |
| 62 | Construye el array de mensajes para OpenRouter. |
| 63 | El system prompt incluye el template real para que la IA conozca |
| 64 | las capacidades de la plantilla. |
| 65 | """ |
| 66 | return [ |
| 67 | { |
| 68 | "role": "system", |
| 69 | "content": ( |
| 70 | "Eres un experto en crear presentaciones con Typst.\n" |
| 71 | "Tu tarea es generar el contenido de una presentación en formato Typst.\n\n" |
| 72 | "ESTRUCTURA:\n" |
| 73 | "- `= Título` → heading level 1 (portada automática)\n" |
| 74 | "- `== Título` → heading level 2 (nueva página, slide de contenido)\n" |
| 75 | "- Listas con `- ` (bullet) o `+ ` (numerada)\n" |
| 76 | "- Código con ```lenguaje ... ``` (tres backticks)\n" |
| 77 | "- Tablas con #table(columns: ..., ...)\n" |
| 78 | "- Imágenes con #image(\"images/placeholder.png\", width: 70%)\n" |
| 79 | "- Dos columnas con #columns(2, gutter: 12pt)[ ... #colbreak() ... ]\n" |
| 80 | "- Énfasis con _cursiva_ (guión bajo). NO uses * para nada — ni énfasis, ni comodines, ni wildcards (como `test_*`).\n\n" |
| 81 | "REGLAS ESTRICTAS:\n" |
| 82 | "1. NO incluyas '#import' — el script lo añade automáticamente\n" |
| 83 | "2. NO incluyas '#show: slides.with(...)' — el script lo añade\n" |
| 84 | "3. El primer heading DEBE ser `= ` (portada del tema) — la respuesta " |
| 85 | "debe EMPEZAR directamente con `= `, sin texto ni comentarios previos\n" |
| 86 | "4. Usa SIEMPRE `==` para slides de contenido\n" |
| 87 | "5. NO uses caracteres como $, \\, # fuera de contexto Typst\n" |
| 88 | "6. No envuelvas el código en bloques markdown (```)\n" |
| 89 | "7. Máximo 6-8 bullet points por slide\n" |
| 90 | "8. Incluye variedad: bullets, código, tablas según el tema\n" |
| 91 | "9. NO uses NUNCA el carácter `*` en ningún contexto — ni para énfasis, ni para comodines (como `test_*`). En Typst `*` abre énfasis/itálica y rompe la compilación. Usa `_cursiva_` para énfasis y `- ` para viñetas.\n" |
| 92 | "TEMPLATE DISPONIBLE:\n" |
| 93 | f"{template_content}\n\n" |
| 94 | f"Genera exactamente {n_slides} slides para el tema \"{tema}\".\n" |
| 95 | "Incluye al menos un bloque de código y una tabla si el tema lo permite." |
| 96 | ), |
| 97 | }, |
| 98 | { |
| 99 | "role": "user", |
| 100 | "content": f"Tema: {tema}\nNúmero de slides: {n_slides}", |
| 101 | }, |
| 102 | ] |
| 103 | |
| 104 | |
| 105 | def llamar_openrouter(messages, model): |
| 106 | """ |
| 107 | Llama a OpenRouter API con reintentos ante rate limit, timeout o error de conexión. |
| 108 | Devuelve el texto generado por la IA. |
| 109 | """ |
| 110 | headers = { |
| 111 | "Authorization": f"Bearer {API_KEY}", |
| 112 | "Content-Type": "application/json", |
| 113 | "HTTP-Referer": "https://atareao.es", |
| 114 | "X-OpenRouter-Title": "typst-ia", |
| 115 | } |
| 116 | |
| 117 | for intento in range(3): |
| 118 | try: |
| 119 | resp = requests.post( |
| 120 | API_URL, |
| 121 | headers=headers, |
| 122 | json={ |
| 123 | "model": model, |
| 124 | "messages": messages, |
| 125 | "temperature": 0.7, |
| 126 | "max_tokens": 4096, |
| 127 | }, |
| 128 | timeout=120, |
| 129 | ) |
| 130 | resp.raise_for_status() |
| 131 | return resp.json()["choices"][0]["message"]["content"] |
| 132 | |
| 133 | except requests.exceptions.HTTPError as e: |
| 134 | if resp.status_code == 429: |
| 135 | wait = int(resp.headers.get("Retry-After", 10)) |
| 136 | print(f" ⏳ Rate limit. Esperando {wait}s...") |
| 137 | time.sleep(wait) |
| 138 | continue |
| 139 | elif resp.status_code == 401: |
| 140 | sys.exit("❌ API Key inválida. Configura OPENROUTER_API_KEY") |
| 141 | elif resp.status_code == 402: |
| 142 | sys.exit("❌ Saldo insuficiente en OpenRouter. Añade créditos en openrouter.ai") |
| 143 | else: |
| 144 | sys.exit(f"❌ Error HTTP {resp.status_code}: {e}") |
| 145 | |
| 146 | except requests.exceptions.Timeout: |
| 147 | if intento < 2: |
| 148 | print(f" ⏳ Timeout. Reintentando ({intento + 1}/3)...") |
| 149 | time.sleep(2 ** intento) |
| 150 | continue |
| 151 | sys.exit("❌ Timeout tras 3 intentos. Prueba otro modelo o más tarde.") |
| 152 | |
| 153 | except requests.exceptions.ConnectionError: |
| 154 | if intento < 2: |
| 155 | print(f" ⚠️ Error de conexión. Reintentando ({intento + 1}/3)...") |
| 156 | time.sleep(2 ** intento) |
| 157 | continue |
| 158 | sys.exit("❌ Error de conexión con OpenRouter. Revisa tu conexión a Internet.") |
| 159 | |
| 160 | |
| 161 | def normalizar_listas(linea): |
| 162 | """ |
| 163 | Convierte una línea de lista con `*` (que Typst interpreta como énfasis/itálica) |
| 164 | en `-` (viñeta correcta de Typst). |
| 165 | |
| 166 | Solo afecta a líneas donde el asterisco va seguido de espacio o está al final |
| 167 | de la línea (lista), NO a *texto* inline ni a **negrita** (estilo Markdown). |
| 168 | """ |
| 169 | m = re.match(r'^(\s*)\*($|\s)', linea) |
| 170 | if m and not linea.strip().startswith('**'): |
| 171 | prefix = m.group(1) |
| 172 | if m.group(2) == ' ': |
| 173 | # * seguido de espacio: mantener el espacio |
| 174 | return prefix + '- ' + linea[m.end():] |
| 175 | # * al final de línea |
| 176 | return prefix + '-' |
| 177 | return linea |
| 178 | |
| 179 | |
| 180 | def convertir_negrita(linea): |
| 181 | """ |
| 182 | Convierte **texto** (estilo Markdown) en _texto_ (strong de Typst). |
| 183 | En Typst ** no es válido y genera el warning 'no text within stars'. |
| 184 | """ |
| 185 | return re.sub(r'\*\*([^*\n]+)\*\*', r'_\1_', linea) |
| 186 | |
| 187 | |
| 188 | def escapar_asteriscos(linea): |
| 189 | """ |
| 190 | Escapa asteriscos sueltos de una línea (fuera de bloques de código) |
| 191 | que Typst interpretaría como énfasis y romperían la compilación. |
| 192 | |
| 193 | Convierte * a \\* para que Typst los muestre literalmente. |
| 194 | Es un safety net para cuando la IA ignora la regla de no usar *. |
| 195 | """ |
| 196 | # No tocar headings (= o ==): ahí * se usa para énfasis válido |
| 197 | if linea.strip().startswith('='): |
| 198 | return linea |
| 199 | # No tocar asteriscos ya escapados |
| 200 | return re.sub(r'(?<!\\)\*', '\\\\*', linea) |
| 201 | |
| 202 | |
| 203 | def limpiar_respuesta(texto): |
| 204 | """ |
| 205 | Limpia la respuesta de la IA: |
| 206 | - Conserva bloques de código ```lang ... ``` como raw blocks Typst válidos |
| 207 | (y los cierra si la IA los dejó abiertos) |
| 208 | - Quita líneas de #import o #show: slides.with (fuera de código) |
| 209 | - Fuera de código: normaliza listas (* → -), convierte **x** → _x_, |
| 210 | y escapa asteriscos sueltos |
| 211 | """ |
| 212 | # ── Pasada 1: separar bloques de código, quitar imports ── |
| 213 | lineas = [] |
| 214 | en_codigo = False |
| 215 | for linea in texto.split('\n'): |
| 216 | stripped = linea.strip() |
| 217 | # Fence de apertura o cierre de bloque de código |
| 218 | if stripped.startswith('```'): |
| 219 | en_codigo = not en_codigo |
| 220 | lineas.append(linea) |
| 221 | continue |
| 222 | if en_codigo: |
| 223 | # Contenido de código: se conserva tal cual |
| 224 | lineas.append(linea) |
| 225 | continue |
| 226 | if stripped.startswith('#import') or stripped.startswith('#show: slides.with'): |
| 227 | continue |
| 228 | lineas.append(linea) |
| 229 | |
| 230 | texto = '\n'.join(lineas) |
| 231 | # Si quedó un bloque sin cerrar, cerrarlo (evita 'unclosed raw text') |
| 232 | if en_codigo: |
| 233 | texto += '\n```' |
| 234 | |
| 235 | # ── Pasada 2: procesar solo líneas fuera de bloques de código ── |
| 236 | resultado = [] |
| 237 | en_codigo = False |
| 238 | for linea in texto.split('\n'): |
| 239 | stripped = linea.strip() |
| 240 | if stripped.startswith('```'): |
| 241 | en_codigo = not en_codigo |
| 242 | resultado.append(linea) |
| 243 | continue |
| 244 | if en_codigo: |
| 245 | resultado.append(linea) |
| 246 | continue |
| 247 | # Fuera de código: normalizar, negrita y escapar asteriscos |
| 248 | linea = normalizar_listas(linea) |
| 249 | linea = convertir_negrita(linea) |
| 250 | linea = escapar_asteriscos(linea) |
| 251 | resultado.append(linea) |
| 252 | return '\n'.join(resultado) |
| 253 | |
| 254 | |
| 255 | def generar_archivo_typ(tema, contenido_typ, output_path, background="fondo-matrix-hd.jpg"): |
| 256 | """ |
| 257 | Genera el archivo .typ completo con: |
| 258 | - Import del template |
| 259 | - Show rule con slides.with() |
| 260 | - Configuración visual (raw, text, heading) |
| 261 | - Contenido generado por la IA |
| 262 | """ |
| 263 | with open(output_path, "w") as f: |
| 264 | f.write('#import "template/slydst.typ": *\n') |
| 265 | f.write('\n') |
| 266 | f.write('#show: slides.with(\n') |
| 267 | f.write(f' title: "{escapar_typst_string(tema)}",\n') |
| 268 | f.write(' subtitle: "Generado con IA",\n') |
| 269 | f.write(f' comment: "Generado con typst-ia",\n') |
| 270 | f.write(f' authors: "Lorenzo Carbonell <a.k.a atareao>",\n') |
| 271 | f.write(' layout: "large",\n') |
| 272 | f.write(' ratio: 16 / 9,\n') |
| 273 | f.write(f' background: "{background}",\n') |
| 274 | f.write(')\n') |
| 275 | f.write('\n') |
| 276 | f.write('#show raw: set block(fill: rgb("1d2433"), width: 100%, inset: 0.6em)\n') |
| 277 | f.write('#set text(size: 10pt)\n') |
| 278 | f.write('#show heading: set text(size: 11pt)\n') |
| 279 | f.write('\n') |
| 280 | f.write(contenido_typ) |
| 281 | f.write('\n') |
| 282 | |
| 283 | print(f" 📄 Archivo .typ guardado: {output_path}") |
| 284 | |
| 285 | |
| 286 | def compilar_typst(typ_path, pdf_path): |
| 287 | """Compila .typ a PDF con typst compile. Devuelve True si éxito.""" |
| 288 | result = subprocess.run( |
| 289 | ["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)], |
| 290 | capture_output=True, |
| 291 | text=True, |
| 292 | cwd=typ_path.parent, |
| 293 | ) |
| 294 | |
| 295 | if result.returncode != 0: |
| 296 | print(f" ❌ Error de compilación Typst:") |
| 297 | print(f" {result.stderr[:600]}") |
| 298 | return False |
| 299 | |
| 300 | print(f" ✅ PDF generado: {pdf_path}") |
| 301 | return True |
| 302 | |
| 303 | |
| 304 | def compilar_con_reintentos(typ_path, pdf_path, messages, model, max_intentos=2): |
| 305 | """ |
| 306 | Compila y si falla, pide a la IA que corrija el error. |
| 307 | Útil porque la IA no siempre genera Typst 100% válido a la primera. |
| 308 | |
| 309 | A diferencia de la versión anterior, cada intento usa mensajes de corrección |
| 310 | FRESCOS para evitar acumulación de correcciones anteriores. |
| 311 | """ |
| 312 | for intento in range(max_intentos + 1): |
| 313 | contenido_actual = typ_path.read_text() |
| 314 | |
| 315 | result = subprocess.run( |
| 316 | ["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)], |
| 317 | capture_output=True, |
| 318 | text=True, |
| 319 | cwd=typ_path.parent, |
| 320 | ) |
| 321 | if result.returncode == 0: |
| 322 | print(f" ✅ PDF generado: {pdf_path}") |
| 323 | return True |
| 324 | if intento == max_intentos: |
| 325 | print(f" ❌ Error tras {max_intentos} correcciones:") |
| 326 | print(f" {result.stderr[:500]}") |
| 327 | return False |
| 328 | |
| 329 | error_msg = result.stderr[:1000] |
| 330 | print(f" ⚠️ Error de compilación. Pidiendo corrección (intento {intento + 1}/{max_intentos})...") |
| 331 | |
| 332 | # Crear mensajes de corrección FRESCOS por intento (evitar acumulación) |
| 333 | correccion_msgs = list(messages) + [ |
| 334 | {"role": "assistant", "content": contenido_actual}, |
| 335 | { |
| 336 | "role": "user", |
| 337 | "content": ( |
| 338 | f"El código Typst tiene este error de compilación:\n" |
| 339 | f"{error_msg}\n\n" |
| 340 | "Corrige SOLO el error. Devuelve TODO el contenido corregido, " |
| 341 | "sin el #import ni el #show, solo el contenido de las slides." |
| 342 | ), |
| 343 | }, |
| 344 | ] |
| 345 | |
| 346 | contenido_corregido = llamar_openrouter(correccion_msgs, model) |
| 347 | if contenido_corregido is None: |
| 348 | print(" ❌ La IA no devolvió una corrección válida.") |
| 349 | return False |
| 350 | contenido_corregido = limpiar_respuesta(contenido_corregido) |
| 351 | |
| 352 | # Reescribir el .typ: preámbulo original + contenido corregido |
| 353 | preambulo = "" |
| 354 | for linea in contenido_actual.split("\n"): |
| 355 | preambulo += linea + "\n" |
| 356 | if linea.strip().startswith("#show heading:"): |
| 357 | break |
| 358 | typ_path.write_text(preambulo + contenido_corregido + "\n") |
| 359 | |
| 360 | return False |
| 361 | |
| 362 | |
| 363 | def abrir_pdf(pdf_path): |
| 364 | """Abre el PDF con el visor por defecto (zathura > xdg-open)""" |
| 365 | if pdf_path.exists(): |
| 366 | # Intentar zathura primero |
| 367 | if subprocess.run(["which", "zathura"], capture_output=True).returncode == 0: |
| 368 | subprocess.Popen(["zathura", str(pdf_path)]) |
| 369 | return |
| 370 | # Fallback a xdg-open |
| 371 | subprocess.run(["xdg-open", str(pdf_path)], check=False) |
| 372 | |
| 373 | |
| 374 | # ── Punto de entrada ──────────────────────────────────────────────────────── |
| 375 | |
| 376 | def main(): |
| 377 | parser = argparse.ArgumentParser( |
| 378 | description="typst-ia: Genera presentaciones Typst con IA", |
| 379 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 380 | epilog="""\ |
| 381 | Ejemplos: |
| 382 | typst-ia "Historia de Linux" |
| 383 | typst-ia "Comandos Git" --slides 8 |
| 384 | typst-ia "Arquitectura de contenedores" --slides 12 --model "anthropic/claude-3.5-sonnet" |
| 385 | typst-ia "Python async/await" --slides 10 --output python-async |
| 386 | """, |
| 387 | ) |
| 388 | parser.add_argument("tema", help="Tema de la presentación (entre comillas si tiene espacios)") |
| 389 | parser.add_argument( |
| 390 | "--slides", type=int, default=10, |
| 391 | help="Número de slides a generar (default: 10)" |
| 392 | ) |
| 393 | parser.add_argument( |
| 394 | "--model", default=DEFAULT_MODEL, |
| 395 | help=f"Modelo en OpenRouter (default: {DEFAULT_MODEL})" |
| 396 | ) |
| 397 | parser.add_argument( |
| 398 | "--output", "-o", |
| 399 | help="Nombre del archivo de salida (sin extensión). Por defecto: slug del tema" |
| 400 | ) |
| 401 | parser.add_argument( |
| 402 | "--retries", type=int, default=0, |
| 403 | help="Intentos de corrección automática si falla la compilación (default: 0)" |
| 404 | ) |
| 405 | parser.add_argument( |
| 406 | "--no-open", action="store_true", |
| 407 | help="No abrir el PDF automáticamente al terminar" |
| 408 | ) |
| 409 | parser.add_argument( |
| 410 | "--background", default="fondo-matrix-hd.jpg", |
| 411 | help="Imagen de fondo en images/ (default: fondo-matrix-hd.jpg)" |
| 412 | ) |
| 413 | |
| 414 | args = parser.parse_args() |
| 415 | |
| 416 | # ── Validaciones ──────────────────────────────────────────────────── |
| 417 | |
| 418 | if not API_KEY: |
| 419 | sys.exit( |
| 420 | "❌ Configura OPENROUTER_API_KEY como variable de entorno\n" |
| 421 | " export OPENROUTER_API_KEY=\"sk-or-v1-tu-api-key-aqui\"" |
| 422 | ) |
| 423 | |
| 424 | # ── Slug para nombres de archivo ──────────────────────────────────── |
| 425 | |
| 426 | if args.output: |
| 427 | slug = args.output |
| 428 | else: |
| 429 | slug = re.sub(r'[^a-z0-9]+', '-', args.tema.lower()).strip('-')[:50] |
| 430 | |
| 431 | typ_path = OUTPUT_DIR / f"{slug}.typ" |
| 432 | pdf_path = OUTPUT_DIR / f"{slug}.pdf" |
| 433 | |
| 434 | # ── Inicio ────────────────────────────────────────────────────────── |
| 435 | |
| 436 | print(f"🚀 typst-ia — Generando presentación") |
| 437 | print(f" Tema: \"{args.tema}\"") |
| 438 | print(f" Slides: {args.slides}") |
| 439 | print(f" Modelo: {args.model}") |
| 440 | print() |
| 441 | |
| 442 | # 1. Leer el template |
| 443 | print("📖 Leyendo template...") |
| 444 | try: |
| 445 | template = leer_template() |
| 446 | except FileNotFoundError as e: |
| 447 | sys.exit(f"❌ {e}") |
| 448 | print(f" Template: {TEMPLATE_FILE.name} ({len(template)} caracteres)") |
| 449 | print() |
| 450 | |
| 451 | # 2. Construir prompt y llamar a OpenRouter |
| 452 | print(f"🤖 Llamando a OpenRouter ({args.model})...") |
| 453 | print(" (esto puede tardar unos segundos)") |
| 454 | messages = construir_system_prompt(args.tema, args.slides, template) |
| 455 | sys.stdout.flush() |
| 456 | contenido = llamar_openrouter(messages, args.model) |
| 457 | if contenido is None: |
| 458 | sys.exit("❌ No se pudo generar contenido tras varios intentos.") |
| 459 | contenido = limpiar_respuesta(contenido) |
| 460 | print(f" Recibidos ~{len(contenido.split())} tokens / ~{len(contenido.split(chr(10)))} líneas") |
| 461 | print() |
| 462 | |
| 463 | # 3. Guardar archivo .typ |
| 464 | print("📝 Generando archivo Typst...") |
| 465 | generar_archivo_typ(args.tema, contenido, typ_path, background=args.background) |
| 466 | print() |
| 467 | |
| 468 | # 4. Compilar a PDF |
| 469 | print("⚙️ Compilando a PDF...") |
| 470 | if args.retries > 0: |
| 471 | exito = compilar_con_reintentos(typ_path, pdf_path, messages, args.model, args.retries) |
| 472 | else: |
| 473 | exito = compilar_typst(typ_path, pdf_path) |
| 474 | |
| 475 | if not exito: |
| 476 | print() |
| 477 | print("🔍 Contenido generado (primeros 600 caracteres):") |
| 478 | print(" " + "-" * 50) |
| 479 | print(contenido[:600]) |
| 480 | print(" " + "-" * 50) |
| 481 | print() |
| 482 | print("💡 Sugerencias:") |
| 483 | print(" - Revisa el archivo .typ y corrige errores manualmente") |
| 484 | print(" - Ejecuta: typst compile --root . " + str(typ_path) + " " + str(pdf_path)) |
| 485 | print(" - Usa --retries 2 para que la IA intente corregir automáticamente") |
| 486 | sys.exit(1) |
| 487 | |
| 488 | print() |
| 489 | |
| 490 | # 5. Abrir PDF |
| 491 | if not args.no_open: |
| 492 | print("📂 Abriendo presentación...") |
| 493 | abrir_pdf(pdf_path) |
| 494 | |
| 495 | print(f"✨ ¡Listo! Presentación generada: {pdf_path}") |
| 496 | |
| 497 | |
| 498 | if __name__ == "__main__": |
| 499 | main() |