Módulo 3: Structured Outputs y System Prompts

6. Prompt Templates y Variables

Descripción

En producción, los prompts raramente son estáticos. Los prompts reales necesitan adaptarse al usuario, al contexto, al idioma, al historial de conversación y a los datos específicos de cada request. Un sistema de templates bien diseñado hace que tus prompts sean mantenibles, testables y reutilizables.

En esta cápsula aprenderás: template systems con f-strings y .format(), Jinja2 para lógica condicional, composición y herencia de templates, gestión de templates en archivos, y manejo de errores comunes.


Por qué los Templates son esenciales en producción

Comparación de enfoques:

EnfoqueProsContrasCuándo usar
String concatenaciónSimpleNo mantenible, propenso a bugsNunca en producción
f-stringsPythónico, simpleSin lógica, sin buclesTemplates simples
.format()Más flexible que f-stringsSin lógicaTemplates con variables dinámicas
Jinja2Lógica completa, herencia, filtrosDependencia extraTemplates complejos
Archivos de templateSeparación de concernsRequiere gestión de archivosEquipos grandes, muchos prompts

Técnica 1: f-strings y .format() (Python nativo)

f-strings para templates simples

from openai import OpenAI

client = OpenAI()

# Template básico con f-string
def clasificar_texto(texto: str, categorias: list[str], idioma: str = "español") -> str:
    categorias_str = ", ".join(categorias)
    
    system = f"Eres un clasificador preciso. Responde solo con una de estas categorías: {categorias_str}."
    user = f"Clasifica en {idioma}:\n\n{texto}"
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": user}
        ],
        temperature=0
    )
    return response.choices[0].message.content.strip()

# Test
result = clasificar_texto(
    texto="No puedo iniciar sesión, dice error 401",
    categorias=["TÉCNICO", "FACTURACIÓN", "CUENTA", "OTRO"]
)
print(result)  # → TÉCNICO o CUENTA

.format() para templates reutilizables

# El método .format() permite definir templates como constantes
PROMPT_CLASIFICACION = """
Clasifica el siguiente texto en una de estas categorías: {categorias}.

Reglas:
- Devuelve SOLO el nombre de la categoría
- Si no estás seguro, devuelve la más probable
- Idioma de respuesta: {idioma}

Texto a clasificar:
{texto}
"""

PROMPT_RESUMEN = """
Resume el siguiente {tipo_documento} en máximo {max_palabras} palabras.
Tono: {tono}
Público: {publico}

{tipo_documento}:
{contenido}
"""

def build_prompt(template: str, **kwargs) -> str:
    """
    Construye prompt a partir de template y variables.
    
    Args:
        template: Template con placeholders {variable}
        **kwargs: Variables para reemplazar en el template
    
    Returns:
        Prompt completo con variables interpoladas
    
    Raises:
        KeyError: Si falta alguna variable requerida
    """
    try:
        return template.format(**kwargs)
    except KeyError as e:
        raise KeyError(f"Variable requerida faltante en template: {e}")

# Test básico
prompt = build_prompt(
    PROMPT_CLASIFICACION,
    categorias="TÉCNICO, FACTURACIÓN, CUENTA, OTRO",
    idioma="español",
    texto="Me cobraron dos veces este mes"
)
print(prompt)

Template con valores por defecto

class PromptBuilder:
    """Builder para construir prompts con valores por defecto y validación."""
    
    DEFAULTS = {
        "idioma": "español",
        "max_tokens": 200,
        "tono": "profesional",
        "nivel_detalle": "medio"
    }
    
    def __init__(self, template: str):
        self.template = template
        self._variables: dict = dict(self.DEFAULTS)
    
    def set(self, **kwargs) -> "PromptBuilder":
        """Establece variables. Retorna self para encadenamiento."""
        self._variables.update(kwargs)
        return self
    
    def build(self) -> str:
        """Construye el prompt final."""
        # Validar que todas las variables requeridas estén definidas
        import string
        formatter = string.Formatter()
        required = {field_name for _, field_name, _, _ in formatter.parse(self.template) if field_name}
        
        missing = required - set(self._variables.keys())
        if missing:
            raise ValueError(f"Variables requeridas faltantes: {missing}")
        
        return self.template.format(**self._variables)
    
    def reset(self) -> "PromptBuilder":
        """Resetea a valores por defecto."""
        self._variables = dict(self.DEFAULTS)
        return self

# Uso con encadenamiento
builder = PromptBuilder(PROMPT_RESUMEN)

prompt = (builder
    .set(tipo_documento="email", 
         max_palabras=50,
         tono="formal",
         publico="directivos",
         contenido="[contenido del email aquí]")
    .build()
)
print(prompt)

Técnica 2: Jinja2 para Templates Complejos

Instalación y setup básico

# pip install jinja2

from jinja2 import Template, Environment, BaseLoader, FileSystemLoader
from jinja2 import TemplateSyntaxError, UndefinedError

# Template básico con Jinja2
template_str = """
Eres un clasificador para {{ dominio }}.
Categorías disponibles: {{ categorias | join(", ") }}

{% if ejemplos %}
Ejemplos de clasificación:
{% for inp, out in ejemplos %}
- Input: "{{ inp }}" → Categoría: {{ out }}
{% endfor %}
{% endif %}

{% if contexto_adicional %}
Contexto importante: {{ contexto_adicional }}
{% endif %}

Clasifica el siguiente texto:
{{ texto }}

{% if formato_json %}
Responde ÚNICAMENTE en JSON: {"categoria": "...", "confianza": 0.0}
{% else %}
Responde ÚNICAMENTE con el nombre de la categoría.
{% endif %}
"""

template = Template(template_str)

prompt = template.render(
    dominio="soporte al cliente SaaS",
    categorias=["TÉCNICO", "FACTURACIÓN", "CUENTA", "OTRO"],
    ejemplos=[
        ("No puedo acceder", "TÉCNICO"),
        ("Me cobraron extra", "FACTURACIÓN"),
        ("Quiero cambiar mi plan", "CUENTA")
    ],
    contexto_adicional="La empresa maneja pagos en MXN y USD",
    texto="El botón de pago no responde al hacer clic",
    formato_json=True
)
print(prompt)

Filtros personalizados de Jinja2

from jinja2 import Environment, BaseLoader

def crear_environment() -> Environment:
    """Crea Environment de Jinja2 con filtros personalizados."""
    env = Environment(loader=BaseLoader())
    
    # Filtro para truncar texto
    def truncate_words(text: str, max_words: int) -> str:
        words = text.split()
        if len(words) <= max_words:
            return text
        return " ".join(words[:max_words]) + "..."
    
    # Filtro para formatear lista como bullet points
    def as_bullets(items: list, marker: str = "-") -> str:
        return "\n".join(f"{marker} {item}" for item in items)
    
    # Filtro para formatear como numerado
    def as_numbered(items: list) -> str:
        return "\n".join(f"{i+1}. {item}" for i, item in enumerate(items))
    
    # Filtro para capitalizar primera letra
    def sentence_case(text: str) -> str:
        return text[0].upper() + text[1:] if text else text
    
    env.filters["truncate_words"] = truncate_words
    env.filters["as_bullets"] = as_bullets
    env.filters["as_numbered"] = as_numbered
    env.filters["sentence_case"] = sentence_case
    
    return env

env = crear_environment()

# Usar filtros personalizados
template = env.from_string("""
Analiza este reporte:

Métricas clave:
{{ metricas | as_bullets }}

Hallazgos del periodo:
{{ hallazgos | as_numbered }}

Contexto: {{ contexto | truncate_words(50) }}
""")

prompt = template.render(
    metricas=["Usuarios: 10,000", "Churn: 5%", "NPS: 42"],
    hallazgos=[
        "Crecimiento de 15% en nuevos usuarios",
        "Caída de engagement en mobile",
        "Pico de tickets de soporte en miércoles"
    ],
    contexto="Este es un reporte largo con mucho contexto que necesitamos truncar para " * 20
)
print(prompt)

Herencia de templates

from jinja2 import Environment, DictLoader

# Templates base y derivados usando herencia
templates = {
    "base_system.j2": """
Eres {{ rol }}.
{% block expertise %}
{% endblock %}

{% block reglas_generales %}
Reglas generales:
- Sé preciso y objetivo
- No inventes información
- Si no sabes algo, di "No tengo información suficiente"
{% endblock %}

{% block formato_output %}
Formato de respuesta: Texto libre.
{% endblock %}
""",

    "expert_analyst.j2": """
{% extends "base_system.j2" %}

{% block expertise %}
Tienes expertise en {{ dominio }} con {{ anos_experiencia }} años de experiencia.
Tu audiencia: {{ audiencia }}.
{% endblock %}

{% block formato_output %}
Estructura tu respuesta:
1. Resumen ejecutivo (2-3 oraciones)
2. Hallazgos principales (lista)
3. Recomendaciones
{% endblock %}
""",

    "json_formatter.j2": """
{% extends "base_system.j2" %}

{% block expertise %}
Especialista en extracción y estructuración de datos.
{% endblock %}

{% block reglas_generales %}
{{ super() }}
- CRÍTICO: Tu output debe ser ÚNICAMENTE JSON válido
- Sin texto antes ni después del JSON
- Usa null para campos no encontrados
{% endblock %}

{% block formato_output %}
Schema JSON requerido:
{{ schema | tojson(indent=2) }}
{% endblock %}
"""
}

env = Environment(loader=DictLoader(templates))

# Renderizar template derivado
expert_template = env.get_template("expert_analyst.j2")
system_prompt = expert_template.render(
    rol="un consultor de negocio senior",
    dominio="estrategia empresarial para startups latinoamericanas",
    anos_experiencia=12,
    audiencia="fundadores de startups en etapa temprana"
)
print(system_prompt)

Técnica 3: Templates desde Archivos

Estructura de carpetas recomendada

prompts/
├── base/
│   ├── base_system.j2
│   └── base_user.j2
├── clasificacion/
│   ├── tickets.j2
│   ├── sentimiento.j2
│   └── intención.j2
├── extraccion/
│   ├── invoice.j2
│   ├── email.j2
│   └── contacto.j2
└── analisis/
    ├── competidores.j2
    └── metricas.j2

Cargar templates desde archivos

from jinja2 import Environment, FileSystemLoader, select_autoescape
from pathlib import Path

class PromptTemplateManager:
    """
    Gestor de templates de prompts desde sistema de archivos.
    
    Soporta:
    - Carga desde directorios
    - Caché automático de templates
    - Recarga en desarrollo
    - Validación de variables requeridas
    """
    
    def __init__(self, templates_dir: str | Path, auto_reload: bool = False):
        self.templates_dir = Path(templates_dir)
        self.env = Environment(
            loader=FileSystemLoader(str(self.templates_dir)),
            autoescape=select_autoescape(["html", "xml"]),
            auto_reload=auto_reload,
            keep_trailing_newline=True
        )
        
        # Registrar filtros personalizados
        self._register_filters()
    
    def _register_filters(self):
        """Registra filtros de Jinja2 útiles para prompts."""
        self.env.filters["as_bullets"] = lambda items: "\n".join(f"- {i}" for i in items)
        self.env.filters["as_numbered"] = lambda items: "\n".join(
            f"{i+1}. {item}" for i, item in enumerate(items)
        )
    
    def render(self, template_name: str, **variables) -> str:
        """
        Renderiza un template con las variables dadas.
        
        Args:
            template_name: Nombre del template (ej: "clasificacion/tickets.j2")
            **variables: Variables para el template
        
        Returns:
            Template renderizado como string
        
        Raises:
            TemplateNotFound: Si el template no existe
            UndefinedError: Si falta una variable requerida
        """
        template = self.env.get_template(template_name)
        return template.render(**variables)
    
    def list_templates(self, category: str | None = None) -> list[str]:
        """Lista templates disponibles, opcionalmente filtrados por categoría."""
        all_templates = self.env.list_templates()
        if category:
            return [t for t in all_templates if t.startswith(category)]
        return all_templates
    
    def validate_variables(self, template_name: str, variables: dict) -> list[str]:
        """
        Valida que las variables requeridas estén presentes.
        
        Returns:
            Lista de variables faltantes (vacía si todo está correcto)
        """
        import re
        template = self.env.get_template(template_name)
        source = template.module.__loader__.get_source(template_name) if hasattr(template, 'module') else ""
        
        # Parsear variables del template
        required = set(re.findall(r"\{\{\s*(\w+)\s*\}\}", source))
        present = set(variables.keys())
        
        return list(required - present)

# Crear templates de ejemplo
import os

def setup_templates_demo():
    """Crea estructura de templates de demo."""
    os.makedirs("prompts/clasificacion", exist_ok=True)
    
    # Template de clasificación de tickets
    ticket_template = """
Eres un clasificador experto de tickets de soporte para {{ empresa }}.

{% if descripcion_empresa %}
Contexto: {{ descripcion_empresa }}
{% endif %}

Categorías disponibles:
{% for cat in categorias %}
- {{ cat.nombre }}: {{ cat.descripcion }}
{% endfor %}

{% if ejemplos %}
Ejemplos:
{% for ejemplo in ejemplos %}
Input: "{{ ejemplo.input }}" → {{ ejemplo.output }}
{% endfor %}
{% endif %}

Clasifica el siguiente ticket y devuelve JSON:
{"categoria": "nombre", "prioridad": "ALTA|MEDIA|BAJA", "confianza": 0.0-1.0}

Sin texto adicional.
"""
    
    with open("prompts/clasificacion/tickets.j2", "w") as f:
        f.write(ticket_template)

setup_templates_demo()

# Usar el manager
manager = PromptTemplateManager("prompts")

system_prompt = manager.render(
    "clasificacion/tickets.j2",
    empresa="TechSaaS",
    descripcion_empresa="Plataforma de gestión de proyectos para equipos de software",
    categorias=[
        {"nombre": "TÉCNICO", "descripcion": "Errores, bugs, problemas de funcionamiento"},
        {"nombre": "FACTURACIÓN", "descripcion": "Cobros, planes, facturas"},
        {"nombre": "CUENTA", "descripcion": "Acceso, contraseña, perfil"},
        {"nombre": "FEATURE_REQUEST", "descripcion": "Solicitudes de nuevas funcionalidades"}
    ],
    ejemplos=[
        {"input": "El dashboard no carga", "output": "TÉCNICO"},
        {"input": "Quiero cambiar mi plan", "output": "CUENTA"}
    ]
)
print(system_prompt)

Técnica 4: Composición de Templates

La composición permite construir prompts complejos ensamblando partes reutilizables.

from dataclasses import dataclass, field
from typing import Callable

@dataclass
class PromptPart:
    """Parte de un prompt con nombre y función generadora."""
    nombre: str
    generar: Callable[..., str]
    requerido: bool = True

class PromptComposer:
    """
    Composer para construir prompts modulares.
    
    Permite definir partes de prompt reutilizables y combinarlas.
    """
    
    def __init__(self, separador: str = "\n\n"):
        self._partes: list[PromptPart] = []
        self.separador = separador
    
    def agregar(self, nombre: str, requerido: bool = True):
        """Decorator para registrar una parte del prompt."""
        def decorator(func: Callable) -> Callable:
            self._partes.append(PromptPart(
                nombre=nombre,
                generar=func,
                requerido=requerido
            ))
            return func
        return decorator
    
    def componer(self, **kwargs) -> str:
        """
        Compone el prompt final con todas las partes.
        
        Args:
            **kwargs: Variables para pasar a cada parte
        
        Returns:
            Prompt completo
        """
        partes_generadas = []
        
        for parte in self._partes:
            try:
                contenido = parte.generar(**kwargs)
                if contenido and contenido.strip():
                    partes_generadas.append(contenido.strip())
            except TypeError:
                # La función no acepta ciertos kwargs, intentar sin ellos
                try:
                    contenido = parte.generar()
                    if contenido and contenido.strip():
                        partes_generadas.append(contenido.strip())
                except Exception as e:
                    if parte.requerido:
                        raise ValueError(f"Error en parte requerida '{parte.nombre}': {e}")
        
        return self.separador.join(partes_generadas)

# Ejemplo de uso del Composer
classifier_composer = PromptComposer()

@classifier_composer.agregar("rol_base", requerido=True)
def generar_rol(empresa: str, dominio: str, **kwargs) -> str:
    return f"Eres un clasificador experto de tickets para {empresa} ({dominio})."

@classifier_composer.agregar("categorias", requerido=True)
def generar_categorias(categorias: list[str], **kwargs) -> str:
    cats_str = "\n".join(f"- {c}" for c in categorias)
    return f"Categorías disponibles:\n{cats_str}"

@classifier_composer.agregar("ejemplos", requerido=False)
def generar_ejemplos(ejemplos: list[tuple] | None = None, **kwargs) -> str:
    if not ejemplos:
        return ""
    lineas = [f'- "{inp}" → {out}' for inp, out in ejemplos]
    return "Ejemplos:\n" + "\n".join(lineas)

@classifier_composer.agregar("formato_output", requerido=True)
def generar_formato(formato: str = "texto", **kwargs) -> str:
    if formato == "json":
        return 'Responde SOLO con JSON: {"categoria": "...", "confianza": 0.0}'
    return "Responde SOLO con el nombre de la categoría."

# Componer prompt
system_prompt = classifier_composer.componer(
    empresa="Acme SaaS",
    dominio="plataforma CRM",
    categorias=["TÉCNICO", "FACTURACIÓN", "CUENTA", "OTRO"],
    ejemplos=[("No puedo exportar", "TÉCNICO"), ("Me cobraron extra", "FACTURACIÓN")],
    formato="json"
)
print(system_prompt)

Técnica 5: Templates con Contexto Dinámico

Para sistemas RAG o con contexto variable, los templates necesitan manejar listas de documentos de tamaño variable.

from jinja2 import Template
import tiktoken

# Estimar tokens para no exceder el context window
def contar_tokens(texto: str, modelo: str = "gpt-4o-mini") -> int:
    """Cuenta tokens de un texto para un modelo específico."""
    try:
        enc = tiktoken.encoding_for_model(modelo)
        return len(enc.encode(texto))
    except Exception:
        # Estimación aproximada si tiktoken falla
        return len(texto.split()) * 1.3

def build_rag_prompt(
    pregunta: str,
    documentos: list[dict],
    max_context_tokens: int = 3000,
    modelo: str = "gpt-4o-mini"
) -> str:
    """
    Construye prompt para RAG con gestión de context window.
    
    Args:
        pregunta: Pregunta del usuario
        documentos: Lista de {"contenido": str, "fuente": str, "relevancia": float}
        max_context_tokens: Máximo de tokens para el contexto
        modelo: Modelo para calcular tokens
    
    Returns:
        Prompt optimizado que respeta el límite de tokens
    """
    # Ordenar por relevancia (mayor primero)
    docs_ordenados = sorted(documentos, key=lambda x: x.get("relevancia", 0), reverse=True)
    
    # Seleccionar documentos que quepan en el context window
    docs_seleccionados = []
    tokens_usados = 0
    
    for doc in docs_ordenados:
        doc_texto = f"Fuente: {doc['fuente']}\n{doc['contenido']}"
        doc_tokens = contar_tokens(doc_texto, modelo)
        
        if tokens_usados + doc_tokens > max_context_tokens:
            break
        
        docs_seleccionados.append(doc)
        tokens_usados += doc_tokens
    
    template = Template("""
Responde la pregunta basándote ÚNICAMENTE en los documentos de contexto proporcionados.

{% if documentos %}
## Contexto relevante
{% for doc in documentos %}
### Documento {{ loop.index }} (Fuente: {{ doc.fuente }})
{{ doc.contenido }}
{% endfor %}
{% else %}
No hay documentos de contexto disponibles.
{% endif %}

## Pregunta
{{ pregunta }}

## Instrucciones
- Si la respuesta está en el contexto, cítalo
- Si no está en el contexto, di: "No encuentro información sobre esto en los documentos disponibles"
- No inventes información que no esté en el contexto
""")
    
    return template.render(
        documentos=docs_seleccionados,
        pregunta=pregunta,
        tokens_info={"total": tokens_usados, "docs_incluidos": len(docs_seleccionados)}
    )

# Test
documentos_ejemplo = [
    {
        "contenido": "FastAPI es un framework moderno para construir APIs con Python 3.7+.",
        "fuente": "docs.fastapi.tiangolo.com",
        "relevancia": 0.95
    },
    {
        "contenido": "La autenticación JWT en FastAPI se implementa con OAuth2PasswordBearer.",
        "fuente": "fastapi.tiangolo.com/tutorial/security",
        "relevancia": 0.87
    },
    {
        "contenido": "Pydantic v2 es significativamente más rápido que v1 gracias a Rust.",
        "fuente": "docs.pydantic.dev",
        "relevancia": 0.60
    }
]

prompt = build_rag_prompt(
    pregunta="¿Cómo implemento autenticación en FastAPI?",
    documentos=documentos_ejemplo,
    max_context_tokens=2000
)
print(prompt)

Técnica 6: Versionado de Templates

En producción, es importante versionar los templates para A/B testing y rollbacks.

from datetime import datetime
import json
from pathlib import Path

class VersionedTemplateManager:
    """
    Gestor de templates con versionado y A/B testing.
    """
    
    def __init__(self, storage_dir: str = "prompt_versions"):
        self.storage_dir = Path(storage_dir)
        self.storage_dir.mkdir(exist_ok=True)
        self._registry: dict = self._load_registry()
    
    def _load_registry(self) -> dict:
        registry_file = self.storage_dir / "registry.json"
        if registry_file.exists():
            return json.loads(registry_file.read_text())
        return {}
    
    def _save_registry(self):
        registry_file = self.storage_dir / "registry.json"
        registry_file.write_text(json.dumps(self._registry, indent=2))
    
    def guardar_version(
        self, 
        nombre: str, 
        template: str, 
        descripcion: str = "",
        autor: str = "sistema"
    ) -> str:
        """Guarda nueva versión de un template."""
        version_id = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
        
        version_data = {
            "template": template,
            "descripcion": descripcion,
            "autor": autor,
            "created_at": datetime.utcnow().isoformat(),
            "version_id": version_id
        }
        
        if nombre not in self._registry:
            self._registry[nombre] = {"versiones": [], "activa": None}
        
        self._registry[nombre]["versiones"].append(version_id)
        
        # Guardar template en archivo
        version_file = self.storage_dir / f"{nombre}_{version_id}.j2"
        version_file.write_text(template)
        
        # Guardar metadata
        meta_file = self.storage_dir / f"{nombre}_{version_id}.json"
        meta_file.write_text(json.dumps(version_data, indent=2))
        
        self._save_registry()
        return version_id
    
    def activar_version(self, nombre: str, version_id: str):
        """Activa una versión específica como la versión actual."""
        if nombre not in self._registry:
            raise ValueError(f"Template '{nombre}' no encontrado")
        if version_id not in self._registry[nombre]["versiones"]:
            raise ValueError(f"Versión '{version_id}' no encontrada")
        
        self._registry[nombre]["activa"] = version_id
        self._save_registry()
    
    def obtener_activo(self, nombre: str, **variables) -> str:
        """Renderiza la versión activa de un template."""
        if nombre not in self._registry:
            raise ValueError(f"Template '{nombre}' no encontrado")
        
        version_id = self._registry[nombre].get("activa")
        if not version_id:
            # Usar la más reciente si no hay activa
            version_id = self._registry[nombre]["versiones"][-1]
        
        version_file = self.storage_dir / f"{nombre}_{version_id}.j2"
        template_str = version_file.read_text()
        
        from jinja2 import Template
        return Template(template_str).render(**variables)
    
    def listar_versiones(self, nombre: str) -> list[dict]:
        """Lista todas las versiones de un template con metadata."""
        if nombre not in self._registry:
            return []
        
        versiones = []
        activa = self._registry[nombre].get("activa")
        
        for version_id in self._registry[nombre]["versiones"]:
            meta_file = self.storage_dir / f"{nombre}_{version_id}.json"
            if meta_file.exists():
                meta = json.loads(meta_file.read_text())
                meta["es_activa"] = version_id == activa
                versiones.append(meta)
        
        return versiones

# Ejemplo de uso
manager = VersionedTemplateManager()

# Versión 1 del template
v1 = manager.guardar_version(
    nombre="clasificador_tickets",
    template="Clasifica en {{ categorias | join(', ') }}. Texto: {{ texto }}",
    descripcion="Versión inicial sin ejemplos",
    autor="mike"
)

# Versión 2 mejorada
v2 = manager.guardar_version(
    nombre="clasificador_tickets",
    template="""
Eres un clasificador experto. Categorías: {{ categorias | join(', ') }}.
{% if ejemplos %}
Ejemplos: {% for e in ejemplos %}{{ e.input }}→{{ e.output }} {% endfor %}
{% endif %}
Clasifica: {{ texto }}. Responde solo con la categoría.
""",
    descripcion="Mejorado con ejemplos few-shot",
    autor="mike"
)

manager.activar_version("clasificador_tickets", v2)

# Renderizar versión activa
prompt = manager.obtener_activo(
    "clasificador_tickets",
    categorias=["TÉCNICO", "FACTURACIÓN", "OTRO"],
    texto="No puedo descargar mi factura del mes pasado",
    ejemplos=[
        {"input": "Error al cargar", "output": "TÉCNICO"},
        {"input": "Cobro duplicado", "output": "FACTURACIÓN"}
    ]
)
print(prompt)

Troubleshooting

1. Variable no definida en Jinja2

Síntoma: UndefinedError: 'variable_name' is undefined

from jinja2 import Template, Undefined

# Solución 1: Usar filtro default
template = Template("""
Hola {{ nombre | default('Usuario') }}.
Idioma: {{ idioma | default('español') }}.
""")

# Solución 2: Usar is defined
template = Template("""
{% if contexto is defined and contexto %}
Contexto: {{ contexto }}
{% endif %}
""")

# Solución 3: Environment con undefined silencioso
from jinja2 import ChainableUndefined

env = Environment(
    loader=BaseLoader(),
    undefined=ChainableUndefined  # Variables no definidas retornan '' en lugar de error
)

# Solución 4: Pasar valores por defecto al renderizar
template = Template("{{ nombre }}")
result = template.render({"nombre": None} | {"nombre": "Fallback"})

2. Inyección de código en templates de usuario

from jinja2 import Environment, sandbox

# NUNCA renderizar templates creados por usuarios sin sandboxing
# MAL:
template_usuario = "{% for i in range(10000) %}x{% endfor %}"  # DoS
Template(template_usuario).render()

# BIEN: Usar SandboxedEnvironment
from jinja2.sandbox import SandboxedEnvironment

safe_env = SandboxedEnvironment()
try:
    result = safe_env.from_string(template_usuario).render()
except Exception as e:
    print(f"Template inseguro bloqueado: {e}")

# Mejor práctica: Solo permitir variables, no lógica de control
def render_usuario_seguro(template_str: str, variables: dict) -> str:
    """
    Renderiza template de usuario con solo interpolación de variables.
    No permite {% %} blocks de control.
    """
    import re
    # Verificar que no hay bloques de control
    if re.search(r"\{%-?\s*(for|if|while|import|from|include|extends)", template_str):
        raise ValueError("Templates de usuario no pueden contener lógica de control")
    
    return safe_env.from_string(template_str).render(variables)

3. Templates muy largos que exceden el context window

import tiktoken

def trim_prompt_to_limit(
    prompt: str, 
    max_tokens: int = 3000,
    modelo: str = "gpt-4o-mini"
) -> tuple[str, bool]:
    """
    Trunca prompt si excede el límite de tokens.
    
    Returns:
        Tuple (prompt_posiblemente_truncado, fue_truncado)
    """
    enc = tiktoken.encoding_for_model(modelo)
    tokens = enc.encode(prompt)
    
    if len(tokens) <= max_tokens:
        return prompt, False
    
    # Truncar y decodificar
    tokens_truncados = tokens[:max_tokens]
    prompt_truncado = enc.decode(tokens_truncados)
    
    # Añadir indicador de truncamiento
    prompt_truncado += "\n\n[...contenido truncado por límite de tokens...]"
    
    return prompt_truncado, True

# Uso
prompt_largo = "Texto muy largo..." * 1000
prompt_final, fue_truncado = trim_prompt_to_limit(prompt_largo, max_tokens=2000)
if fue_truncado:
    print(f"⚠️ Prompt truncado a 2000 tokens")

4. Templates con caracteres especiales que rompen el formato

from jinja2 import Template

# Problema: Variables que contienen llaves o caracteres de Jinja2
usuario_input = "Usa el formato {{ dato }} en tu respuesta"

# Solución: Escapar la variable antes de usar en template
template = Template("""
El usuario preguntó: {{ pregunta | e }}
Responde a su pregunta.
""")
# El filtro |e (escape) maneja caracteres especiales de HTML
# Para Jinja2 mismo, los {{ }} en variables se tratan como texto, no se ejecutan

# Para incluir llaves literales en el template:
template_con_llaves_literales = Template("""
El formato JSON es: {{ '{{' }}campo{{ '}}' }}
""")
# O usando bloques raw:
template_raw = Template("""
{% raw %}El modelo debe responder en: {"key": "value"}{% endraw %}
""")

Ejercicios

Ejercicio 1: Template con condicional para few-shot opcional

Crea un template Jinja2 que incluya ejemplos only if ejemplos no está vacío, con manejo de lista de tamaño variable.

Ver solución
from jinja2 import Template

TEMPLATE_FEW_SHOT = Template("""
Eres un clasificador para el dominio: {{ dominio }}.
Categorías: {{ categorias | join(", ") }}.

{% if ejemplos and ejemplos | length > 0 %}
## Ejemplos de clasificación:
{% for ejemplo in ejemplos %}
{{ loop.index }}. Input: "{{ ejemplo.input }}"
   Categoría: {{ ejemplo.output }}
   {% if ejemplo.razon is defined %}
   Razón: {{ ejemplo.razon }}
   {% endif %}
{% endfor %}

{% endif %}
## Tarea
Clasifica el siguiente texto en una de las categorías listadas.
Texto: {{ texto }}

{% if formato_json %}
Responde ÚNICAMENTE en JSON: {"categoria": "NOMBRE", "confianza": 0.0-1.0}
{% else %}
Responde ÚNICAMENTE con el nombre de la categoría.
{% endif %}
""")

# Test 1: Sin ejemplos
prompt_sin_ejemplos = TEMPLATE_FEW_SHOT.render(
    dominio="soporte técnico",
    categorias=["TÉCNICO", "FACTURACIÓN", "OTRO"],
    ejemplos=[],
    texto="No puedo hacer login",
    formato_json=True
)

# Test 2: Con ejemplos
prompt_con_ejemplos = TEMPLATE_FEW_SHOT.render(
    dominio="soporte técnico",
    categorias=["TÉCNICO", "FACTURACIÓN", "OTRO"],
    ejemplos=[
        {"input": "Error 500", "output": "TÉCNICO", "razon": "Error de servidor"},
        {"input": "Me cobraron 2 veces", "output": "FACTURACIÓN"},
    ],
    texto="No puedo hacer login",
    formato_json=True
)

print("=== Sin ejemplos ===")
print(prompt_sin_ejemplos)
print("\n=== Con ejemplos ===")
print(prompt_con_ejemplos)

Ejercicio 2: Template desde archivo con FileSystemLoader

Crea el directorio prompts/, guarda un template en prompts/resumen.j2, y cárgalo con FileSystemLoader.

Ver solución
import os
from jinja2 import Environment, FileSystemLoader

# 1. Crear directorio y template
os.makedirs("prompts", exist_ok=True)

template_content = """
Eres un especialista en crear resúmenes {{ tipo_resumen }}.

Genera un resumen del siguiente {{ tipo_documento }}.

Requisitos:
- Longitud: {{ longitud }} palabras aproximadamente
- Tono: {{ tono | default("profesional") }}
- Incluir: {% for item in incluir %}"{{ item }}"{% if not loop.last %}, {% endif %}{% endfor %}
{% if excluir is defined and excluir %}
- Excluir: {% for item in excluir %}"{{ item }}"{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}

{{ tipo_documento | capitalize }} a resumir:
{{ contenido }}
"""

with open("prompts/resumen.j2", "w", encoding="utf-8") as f:
    f.write(template_content)

# 2. Cargar y renderizar desde FileSystemLoader
env = Environment(loader=FileSystemLoader("prompts"))
template = env.get_template("resumen.j2")

prompt = template.render(
    tipo_resumen="ejecutivos para directivos",
    tipo_documento="artículo",
    longitud=150,
    tono="formal",
    incluir=["puntos clave", "conclusiones", "recomendaciones"],
    excluir=["detalles técnicos", "jerga especializada"],
    contenido="""
    La adopción de IA en empresas latinoamericanas creció un 45% en 2024.
    Los principales usos son automatización de procesos y análisis de datos.
    Las barreras principales son costos de implementación y falta de talento.
    Las startups lideran la adopción, seguidas por grandes corporaciones.
    """
)
print(prompt)

Ejercicio 3: Composer de prompts modular para diferentes tareas

Implementa un PromptComposer con partes: rol, tarea, restricciones, formato. Cada parte debe ser opcional excepto rol y tarea.

Ver solución
from jinja2 import Template

class ModularPromptComposer:
    """Composer modular para prompts con secciones configurables."""
    
    SECTION_TEMPLATES = {
        "rol": Template("Eres {{ rol_descripcion }}."),
        "tarea": Template("""
## Tarea
{{ descripcion_tarea }}
{% if subtareas %}
Pasos a seguir:
{% for subtarea in subtareas %}
{{ loop.index }}. {{ subtarea }}
{% endfor %}
{% endif %}
"""),
        "restricciones": Template("""
## Restricciones
{% for restriccion in restricciones %}
- {{ restriccion }}
{% endfor %}
"""),
        "formato": Template("""
## Formato de respuesta
{% if tipo_formato == "json" %}
Responde ÚNICAMENTE con JSON válido: {{ schema_json }}
{% elif tipo_formato == "lista" %}
Responde con una lista numerada.
{% elif tipo_formato == "markdown" %}
Usa formato Markdown con secciones claras.
{% else %}
{{ instrucciones_formato }}
{% endif %}
"""),
        "ejemplos": Template("""
## Ejemplos
{% for ejemplo in ejemplos %}
Input: {{ ejemplo.input }}
Output: {{ ejemplo.output }}
{% endfor %}
""")
    }
    
    def __init__(self):
        self._secciones: dict = {}
        self._orden: list[str] = ["rol", "tarea", "ejemplos", "restricciones", "formato"]
    
    def configurar(self, seccion: str, **kwargs) -> "ModularPromptComposer":
        """Configura una sección del prompt."""
        self._secciones[seccion] = kwargs
        return self
    
    def componer(self) -> str:
        """Compone el prompt final."""
        requeridos = ["rol", "tarea"]
        for req in requeridos:
            if req not in self._secciones:
                raise ValueError(f"Sección requerida faltante: '{req}'")
        
        partes = []
        for seccion in self._orden:
            if seccion in self._secciones:
                template = self.SECTION_TEMPLATES[seccion]
                try:
                    contenido = template.render(**self._secciones[seccion]).strip()
                    if contenido:
                        partes.append(contenido)
                except Exception as e:
                    print(f"Warning: Error en sección '{seccion}': {e}")
        
        return "\n\n".join(partes)

# Test
composer = ModularPromptComposer()

prompt = (composer
    .configurar("rol", rol_descripcion="un analista de seguridad experto en APIs REST")
    .configurar("tarea", 
        descripcion_tarea="Revisa el código de la API y detecta vulnerabilidades de seguridad.",
        subtareas=["Identificar endpoints sin autenticación", "Verificar validación de inputs", "Detectar SQL injection potencial"])
    .configurar("restricciones", restricciones=[
        "No generes exploits o código malicioso",
        "Clasifica cada vulnerabilidad como CRÍTICA, ALTA, MEDIA o BAJA",
        "Proporciona recomendaciones de remediación"
    ])
    .configurar("formato", 
        tipo_formato="json",
        schema_json='{"vulnerabilidades": [{"descripcion": "", "nivel": "", "remediacion": ""}]}')
    .componer()
)
print(prompt)

Ejercicio 4: Template con conteo de tokens y truncamiento automático

Implementa una función que construya un prompt RAG y garantice que no exceda max_tokens truncando documentos de menor relevancia.

Ver solución
from jinja2 import Template
import tiktoken

def build_rag_prompt_limitado(
    pregunta: str,
    documentos: list[dict],
    system_base: str,
    max_tokens: int = 4000,
    modelo: str = "gpt-4o-mini"
) -> tuple[str, dict]:
    """
    Construye prompt RAG respetando límite de tokens.
    
    Returns:
        Tuple (prompt, metadata) donde metadata incluye docs incluidos/excluidos
    """
    enc = tiktoken.encoding_for_model(modelo)
    
    def contar(texto: str) -> int:
        return len(enc.encode(texto))
    
    # Tokens fijos del sistema y pregunta
    tokens_base = contar(system_base) + contar(pregunta) + 100  # 100 de overhead
    tokens_disponibles = max_tokens - tokens_base
    
    # Ordenar por relevancia
    docs_ordenados = sorted(
        documentos, 
        key=lambda x: x.get("relevancia", 0.5), 
        reverse=True
    )
    
    docs_incluidos = []
    docs_excluidos = []
    tokens_usados = 0
    
    for doc in docs_ordenados:
        doc_str = f"[{doc['fuente']}]\n{doc['contenido']}"
        doc_tokens = contar(doc_str)
        
        if tokens_usados + doc_tokens <= tokens_disponibles:
            docs_incluidos.append(doc)
            tokens_usados += doc_tokens
        else:
            docs_excluidos.append(doc['fuente'])
    
    template = Template("""
{{ system_base }}

## Documentos de referencia ({{ docs | length }} de {{ total_docs }} disponibles)
{% for doc in docs %}
### [{{ loop.index }}] {{ doc.fuente }} (relevancia: {{ "%.0f%%" | format(doc.relevancia * 100) }})
{{ doc.contenido }}
{% endfor %}
{% if excluidos %}
({{ excluidos | length }} documentos adicionales excluidos por límite de tokens)
{% endif %}

## Pregunta del usuario
{{ pregunta }}

Responde basándote únicamente en los documentos de referencia.
""")
    
    prompt = template.render(
        system_base=system_base,
        docs=docs_incluidos,
        total_docs=len(documentos),
        excluidos=docs_excluidos,
        pregunta=pregunta
    )
    
    metadata = {
        "total_tokens": contar(prompt),
        "docs_incluidos": len(docs_incluidos),
        "docs_excluidos": docs_excluidos,
        "truncado": len(docs_excluidos) > 0
    }
    
    return prompt, metadata

# Test
docs_test = [
    {"contenido": "FastAPI es un framework moderno y rápido.", "fuente": "fastapi.io", "relevancia": 0.95},
    {"contenido": "Uvicorn es el servidor ASGI recomendado para FastAPI.", "fuente": "uvicorn.org", "relevancia": 0.80},
    {"contenido": "Starlette es la base de FastAPI para routing.", "fuente": "starlette.io", "relevancia": 0.70},
    {"contenido": "Python 3.11 introduce mejoras de rendimiento del 60%.", "fuente": "python.org", "relevancia": 0.30},
]

prompt, meta = build_rag_prompt_limitado(
    pregunta="¿Cómo arranco un servidor FastAPI en producción?",
    documentos=docs_test,
    system_base="Eres un experto en Python y APIs REST.",
    max_tokens=500
)
print(prompt[:300] + "...")
print(f"\nMetadata: {meta}")

Ejercicio 5: Sistema de A/B testing de prompts

Implementa una función que ejecute el mismo query con dos versiones de prompt y compare los resultados.

Ver solución
from openai import OpenAI
import json
from dataclasses import dataclass

client = OpenAI()

@dataclass
class ABTestResult:
    version_a_output: str
    version_b_output: str
    tokens_a: int
    tokens_b: int
    latencia_a_ms: float
    latencia_b_ms: float

def ab_test_prompts(
    prompt_a: str,
    prompt_b: str,
    user_input: str,
    model: str = "gpt-4o-mini",
    n_trials: int = 3
) -> ABTestResult:
    """
    Ejecuta A/B test entre dos versiones de prompt.
    Ejecuta múltiples trials y retorna el último resultado.
    """
    import time
    
    def llamar(system: str) -> tuple[str, int, float]:
        start = time.time()
        response = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system},
                {"role": "user", "content": user_input}
            ]
        )
        elapsed_ms = (time.time() - start) * 1000
        return (
            response.choices[0].message.content,
            response.usage.total_tokens,
            elapsed_ms
        )
    
    # Ejecutar últimos trials
    output_a, tokens_a, lat_a = llamar(prompt_a)
    output_b, tokens_b, lat_b = llamar(prompt_b)
    
    result = ABTestResult(
        version_a_output=output_a,
        version_b_output=output_b,
        tokens_a=tokens_a,
        tokens_b=tokens_b,
        latencia_a_ms=lat_a,
        latencia_b_ms=lat_b
    )
    
    print(f"\n=== RESULTADO A/B TEST ===")
    print(f"Input: '{user_input[:50]}'")
    print(f"\nVersionA ({tokens_a} tokens, {lat_a:.0f}ms):")
    print(f"  {output_a[:100]}...")
    print(f"\nVersionB ({tokens_b} tokens, {lat_b:.0f}ms):")
    print(f"  {output_b[:100]}...")
    print(f"\nDiferencia tokens: {tokens_b - tokens_a:+d}")
    print(f"Diferencia latencia: {lat_b - lat_a:+.0f}ms")
    
    return result

# Test
PROMPT_V1 = "Clasifica el ticket en TÉCNICO, FACTURACIÓN o CUENTA. Solo la categoría."
PROMPT_V2 = """
Eres un clasificador experto de soporte. 
Categorías: TÉCNICO (bugs, errores), FACTURACIÓN (cobros, pagos), CUENTA (acceso, perfil).
Clasifica el ticket y responde SOLO con la categoría.
"""

result = ab_test_prompts(
    prompt_a=PROMPT_V1,
    prompt_b=PROMPT_V2,
    user_input="No puedo descargar mi factura del mes de enero"
)

Resumen

TécnicaCaso de usoComplejidad
f-stringsPrompts simples con variablesBaja
.format()Templates como constantes reutilizablesBaja
Jinja2 inlineCondicionales y loops en promptsMedia
Jinja2 FileSystemEquipos con prompts en archivosMedia
Herencia Jinja2Sistema con base + variantesAlta
PromptComposerPartes modulares y reutilizablesAlta
VersionadoProducción con A/B testingAlta

Recursos adicionales

  1. Jinja2 Documentation
  2. Jinja2 Template Designer Guide
  3. LangChain PromptTemplate
  4. tiktoken - OpenAI Token Counter
  5. Prompt Engineering Guide - Templates