Zuletzt aktiv 6 hours ago

typst-ia.py Originalformat
1#!/usr/bin/env python3
2"""
3typst-ia: Genera presentaciones Typst con IA vía OpenRouter.
4
5Uso:
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
11Dependencias:
12 pip install requests
13
14Requiere Typst instalado:
15 sudo apt install typst # Debian/Ubuntu
16 sudo pacman -S typst # Arch
17"""
18
19import argparse
20import json
21import os
22import re
23import subprocess
24import sys
25import time
26from pathlib import Path
27
28import requests
29
30
31# ── Constantes ──────────────────────────────────────────────────────────────
32
33API_KEY = os.environ.get("OPENROUTER_API_KEY")
34API_URL = "https://openrouter.ai/api/v1/chat/completions"
35DEFAULT_MODEL = "deepseek/deepseek-chat"
36
37# Directorio donde está este script
38SCRIPT_DIR = Path(__file__).resolve().parent
39TEMPLATE_FILE = SCRIPT_DIR / "template" / "slydst.typ"
40OUTPUT_DIR = SCRIPT_DIR
41
42
43# ── Funciones auxiliares ────────────────────────────────────────────────────
44
45def 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
55def escapar_typst_string(texto):
56 """Escapa caracteres especiales en strings Typst (comillas, saltos de línea)"""
57 return texto.replace('"', '\\"').replace('\n', ' ')
58
59
60def 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
105def 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
161def 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
180def 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
188def 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
203def 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
255def 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
286def 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
304def 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
363def 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
376def main():
377 parser = argparse.ArgumentParser(
378 description="typst-ia: Genera presentaciones Typst con IA",
379 formatter_class=argparse.RawDescriptionHelpFormatter,
380 epilog="""\
381Ejemplos:
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
498if __name__ == "__main__":
499 main()