Ultima attività 8 hours ago

atareao's Avatar atareao ha revisionato questo gist 8 hours ago. Vai alla revisione

1 file changed, 499 insertions

typst-ia.py(file creato)

@@ -0,0 +1,499 @@
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()
Più nuovi Più vecchi