---
title: "FastHTML + PydanticAI: crea una aplicación web de IA en Python"
description: "Crea un generador de títulos con IA usando FastHTML y PydanticAI. Aprende salida estructurada, integración con OpenRouter y buenas prácticas para aplicaciones web en Python, paso a paso."
date: 2025-03-03
categories: ["web-development"]
tags: ["fasthtml","pydantic-ai","openrouter"]
---

import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";
import Button from "@components/widgets/Button.astro";

Bienvenido a la parte 3 de nuestra serie de FastHTML. En este tutorial construimos una aplicación web con IA usando FastHTML y PydanticAI, dos bibliotecas de Python para aplicaciones web full-stack con salida de IA estructurada. Sin necesidad de frameworks de JavaScript.

Si eres nuevo en FastHTML, empieza con [FastHTML para principiantes](/es/empezar-fasthtml/), donde cubrimos lo básico, y sigue con nuestro tutorial de [estructura FastHTML multipágina](/es/fasthtml-multiples-paginas/). ¿Nuevo en la integración de IA? Lee nuestra [guía para principiantes de programación con IA](/es/guia-programacion-ia-principiantes/). ¿Quieres saber cómo se compara FastHTML con otras opciones? Mira nuestro resumen de los [mejores frameworks web de Python](/best-python-web-frameworks/).

Nuestro proyecto es un Generador de Títulos con IA que ayuda a creadores de contenido a desarrollar títulos para blogs, vídeos de YouTube, publicaciones en redes sociales y más. Usamos la salida estructurada de PydanticAI para obtener respuestas validadas y con tipado seguro del modelo de IA, y OpenRouter para un acceso a modelos económico e independiente del proveedor.

<Notice type="info" title="Versiones objetivo">
Este tutorial está orientado a **PydanticAI >= v2.0** (estable desde junio de 2026) y **FastHTML >= 0.14.11**. Si usas una versión anterior, consulta la [guía de actualización de PydanticAI](https://pydantic.dev/docs/ai/project/changelog/).
</Notice>

<ListCheck>
**Lo que construirás:**
- Un formulario funcional de generador de títulos con IA con opciones de plataforma y estilo
- Salida estructurada de PydanticAI con respuestas de IA validadas y con tipado seguro (sin análisis con regex)
- Integración con OpenRouter mediante el proveedor nativo de PydanticAI
- Una estructura de proyecto Python modular que puedes reutilizar para otras herramientas de IA
</ListCheck>

## Resumen de la estructura del proyecto

Esta es la estructura exacta que vamos a construir:



<YouTubeEmbed
  url="https://www.youtube.com/embed/omVqE81ssHc"
  label="Herramientas de IA con FastHTML"
/>
```
ai-title-generator/
├── main.py                # Main application entry point
├── config.py              # Configuration settings
├── ai_service.py          # AI integration module
├── components/            # Reusable UI components
│   ├── __init__.py
│   ├── header.py          # Page header
│   ├── footer.py          # Page footer
│   └── page_layout.py     # Layout template
├── pages/                 # Individual page content
│   ├── __init__.py
│   ├── home.py            # Home page
│   └── title_generator.py # Title generator page
└── tools/                 # AI tools
    ├── __init__.py
    └── title_generator.py # Title generation tool
```

Esta estructura sigue el mismo [patrón modular del tutorial multipágina](/es/fasthtml-multiples-paginas/). Cada módulo gestiona una responsabilidad. Los componentes de UI son reutilizables entre páginas. Los distintos aspectos de la app se agrupan lógicamente, y añadir nuevas funciones o herramientas de IA significa crear un archivo nuevo en lugar de editar un monolito.

## Paso a paso: construye tu generador de títulos con IA

Cada paso incluye una verificación para que puedas confirmar que todo funciona antes de continuar.

### Paso 1: configuración del proyecto

Crea el directorio del proyecto, el entorno virtual e instala las dependencias:

```bash
mkdir -p ai-title-generator/components ai-title-generator/pages ai-title-generator/tools
cd ai-title-generator
touch components/__init__.py pages/__init__.py tools/__init__.py
python3 -m venv .venv
source .venv/bin/activate
pip install python-fasthtml python-dotenv "pydantic-ai[openrouter]"
```

<Notice type="warning" title="Modelo retirado">
El modelo `openai/gpt-3.5-turbo`, antes recomendado por defecto, fue retirado por OpenAI. Este tutorial usa `openai/gpt-4o-mini` (~$0.15/$0.60 por 1M de tokens vía OpenRouter) como opción por defecto económica y fiable.
</Notice>

<Notice type="info" title="Modelos gratuitos">
OpenRouter ofrece modelos gratuitos con límites de uso (~50 solicitudes/día). También puedes usar el alias `~openai/gpt-latest` para obtener siempre el último modelo GPT. Consulta [openrouter.ai/models](https://openrouter.ai/models) para ver el catálogo completo.
</Notice>

Tanto `python-fasthtml` como `pydantic-ai` requieren **Python >= 3.10**. Comprueba tu versión con `python3 --version`.

Crea un `.gitignore` para mantener los secretos fuera del control de versiones:

**Archivo: `.gitignore`**

```
.env
.venv/
__pycache__/
```

Crea un `requirements.txt` para fijar tus dependencias:

**Archivo: `requirements.txt`**

```
python-fasthtml>=0.14.11
python-dotenv>=1.0
pydantic-ai[openrouter]>=2.0
```

Ahora crea el archivo de configuración:

**Archivo: `config.py`**

```python
import os
import sys
from dotenv import load_dotenv

load_dotenv()

OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
DEBUG = os.getenv("DEBUG", "True").lower() == "true"
APP_NAME = "AI Title Generator"

if not OPENROUTER_API_KEY:
    print("ERROR: OPENROUTER_API_KEY is not set. Create a .env file with your key.")
    print("Get a free key at https://openrouter.ai/keys")
    sys.exit(1)
```

Crea el archivo `.env` con tu clave de API de OpenRouter:

**Archivo: `.env`**

```
OPENROUTER_API_KEY=your_openrouter_api_key_here
DEFAULT_MODEL=openai/gpt-4o-mini
DEBUG=True
```

**Verifica:** Confirma que las dependencias están instaladas y los imports funcionan:

```bash
pip list | grep pydantic-ai
# Should show pydantic-ai >= 2.0

python -c "from pydantic_ai.models.openrouter import OpenRouterModel; print('OK')"
# Should print: OK
```

**Si falla:**
- `ModuleNotFoundError: No module named 'pydantic_ai.models.openrouter'` — instalaste `pydantic-ai` sin el extra `[openrouter]`. Vuelve a ejecutar: `pip install "pydantic-ai[openrouter]"`
- Python < 3.10 — actualiza Python
- `OPENROUTER_API_KEY is not set` — crea o corrige tu archivo `.env`

### Paso 2: creación del servicio de IA

Este es el cambio principal. El código antiguo construía manualmente un cliente `AsyncOpenAI` y usaba chat completions sin procesar. PydanticAI ahora tiene soporte nativo para OpenRouter con `OpenRouterModel` y `OpenRouterProvider`, lo que significa menos código repetitivo y salida estructurada integrada.

**Archivo: `ai_service.py`**

```python
from pydantic_ai import Agent, ModelSettings
from pydantic_ai.models.openrouter import OpenRouterModel
from pydantic_ai.providers.openrouter import OpenRouterProvider
import config

import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";


class AIService:
    """Service for interacting with AI models via OpenRouter."""

    def __init__(self, model_name=None, output_type=None, system_prompt=None):
        self.model_name = model_name or config.DEFAULT_MODEL
        model = OpenRouterModel(
            self.model_name,
            provider=OpenRouterProvider(api_key=config.OPENROUTER_API_KEY),
        )
        self.agent = Agent(
            model,
            instructions=system_prompt,
            output_type=output_type,
        )

    async def run_structured(self, user_message: str, temperature: float = 0.8):
        """Run the agent and return validated structured output."""
        result = await self.agent.run(
            user_message,
            model_settings=ModelSettings(temperature=temperature),
        )
        return result.output
```

Compara esto con la versión anterior que tenía ~90 líneas con `AsyncOpenAI`, `OpenAIModel`, `chat_completion()`, `structured_completion()` y construcción manual de mensajes.

<Tabs>
<Tab name="OpenRouter (recomendado)">
El código anterior usa el proveedor nativo de OpenRouter de PydanticAI. Instálalo con:

```bash
pip install "pydantic-ai[openrouter]"
```

Esta es la vía recomendada. No hace falta configurar el cliente manualmente.
</Tab>
<Tab name="Compatible con OpenAI directo">
Si no quieres el extra `[openrouter]`, puedes usar la alternativa compatible con OpenAI:

```python
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

model = OpenAIChatModel(
    "openai/gpt-4o-mini",
    provider=OpenAIProvider(
        base_url="https://openrouter.ai/api/v1",
        api_key=config.OPENROUTER_API_KEY,
    ),
)
```

Esto funciona con cualquier API compatible con OpenAI, no solo con OpenRouter.
</Tab>
</Tabs>

<Accordion label="¿Por qué salida estructurada en lugar de chat sin procesar?" group="explanation">
El tutorial anterior definía modelos Pydantic (`TitleGenerationResponse`) pero luego llamaba a `chat_completion()` y analizaba la respuesta de texto con regex. Eso es frágil: los LLM cambian el formato, añaden markdown o incluyen texto extra.

La salida estructurada de PydanticAI envía el esquema Pydantic al modelo y valida la respuesta contra él. Si el modelo devuelve algo que no coincide, PydanticAI reintenta automáticamente. Obtienes datos con tipado seguro directamente: `result.output.titles` es una `list[str]`, no una cadena que tienes que analizar.

Esto significa menos código y menos modos de fallo.
</Accordion>

**Verifica:** Confirma que el módulo se importa sin problemas:

```bash
python -c "from ai_service import AIService; print('AIService imported OK')"
```

### Paso 3: creación de la herramienta generadora de títulos

La salida estructurada sustituye aquí al frágil análisis con regex. El antiguo método `_extract_titles_from_response()` (más de 40 líneas de regex, lógica de respaldo y división de cadenas) ha desaparecido por completo.

**Archivo: `tools/title_generator.py`**

```python
from pydantic import BaseModel
from pydantic_ai import Agent, ModelSettings
from pydantic_ai.models.openrouter import OpenRouterModel
from pydantic_ai.providers.openrouter import OpenRouterProvider
import config


class TitleGenerationResponse(BaseModel):
    """Schema for title generation response."""
    titles: list[str]


SYSTEM_PROMPT = """You are an expert title generator specializing in creating
engaging, click-worthy titles appropriate for different platforms.

Guidelines:
- Create titles that grab attention without being misleading
- Adapt the style and format to the specified platform
- Ensure titles are relevant to the topic
- Keep titles concise and effective
- Return exactly the number of titles requested"""


class TitleGenerator:
    """Tool for generating titles for various platforms."""

    def __init__(self):
        model = OpenRouterModel(
            config.DEFAULT_MODEL,
            provider=OpenRouterProvider(api_key=config.OPENROUTER_API_KEY),
        )
        self.agent = Agent(
            model,
            instructions=SYSTEM_PROMPT,
            output_type=TitleGenerationResponse,
            model_settings=ModelSettings(temperature=0.8),
        )

    async def generate_titles(
        self,
        topic: str,
        platform: str = "Blog",
        style: str = "Professional",
        number_of_titles: int = 5,
    ) -> list[str]:
        """Generate titles based on the given parameters."""
        user_prompt = (
            f"Generate {number_of_titles} engaging {platform} titles about: {topic}\n"
            f"Style: {style}"
        )
        result = await self.agent.run(user_prompt)
        return result.output.titles[:number_of_titles]
```

<Notice type="success" title="Ventaja de la salida estructurada">
Con la salida estructurada de PydanticAI ya no necesitamos análisis con regex. La respuesta de la IA se valida directamente contra nuestro modelo Pydantic, lo que significa menos líneas de código y resultados más fiables. Si el modelo devuelve menos títulos de los pedidos, `result.output.titles` simplemente tiene menos elementos. Si la respuesta no coincide con el esquema, PydanticAI reintenta automáticamente.
</Notice>

### Paso 4: creación de los componentes de UI

Ahora crea los componentes de UI reutilizables. El encabezado, el pie de página y el layout apenas cambian respecto al original.

**Archivo: `components/header.py`**

```python
from fasthtml.common import *
import config


def header(current_page="/"):
    """Creates a consistent header with navigation."""
    nav_items = [
        ("Home", "/"),
        ("Title Generator", "/title-generator"),
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path
        link_class = "text-white hover:text-gray-300 px-3 py-2"
        if is_current:
            link_class += " font-bold underline"

        nav_links.append(
            Li(A(title, href=path, cls=link_class))
        )

    return Header(
        Div(
            A(config.APP_NAME, href="/", cls="text-xl font-bold text-white"),
            Nav(
                Ul(*nav_links, cls="flex space-x-2"),
                cls="ml-auto"
            ),
            cls="container mx-auto flex items-center justify-between px-4 py-3"
        ),
        cls="bg-blue-600 shadow-md"
    )
```

**Archivo: `components/footer.py`**

```python
from fasthtml.common import *
import config


def footer():
    """Creates a consistent footer."""
    return Footer(
        Div(
            P(f"\u00a9 2026 {config.APP_NAME}. Built with FastHTML and PydanticAI.",
              cls="text-center text-gray-500"),
            cls="container mx-auto px-4 py-6"
        ),
        cls="bg-gray-100 mt-auto"
    )
```

**Archivo: `components/page_layout.py`**

```python
from fasthtml.common import *
from .header import header
from .footer import footer


def page_layout(title, content, current_page="/"):
    """Creates a consistent page layout with header and footer."""
    return Html(
        Head(
            Title(title),
            Meta(charset="UTF-8"),
            Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
            Script(src="https://cdn.tailwindcss.com"),
        ),
        Body(
            Div(
                header(current_page),
                Main(
                    Div(content, cls="container mx-auto px-4 py-8"),
                    cls="flex-grow"
                ),
                footer(),
                cls="flex flex-col min-h-screen"
            )
        )
    )
```

<Notice type="warning" title="Cambio en la sintaxis de Label">
FastHTML ahora usa `fr=` en lugar de `For=` para el atributo `for` de las etiquetas (ya que `for` es una palabra reservada de Python). Todas las etiquetas de este tutorial usan la sintaxis actualizada. Si tus etiquetas no son clicables o no enfocan el campo, revisa esto primero.
</Notice>

Nota: Tailwind CSS vía CDN está bien para desarrollo y prototipos. Para producción, usa un paso de compilación o considera [MonsterUI](https://monsterui.answer.ai/) como la biblioteca de componentes basada en Tailwind recomendada para FastHTML.

### Paso 5: creación de las páginas

La página de inicio no cambia. La página del generador de títulos recibe dos correcciones: `For=` → `fr=` en todas las etiquetas, y un botón de copiar más seguro.

**Archivo: `pages/home.py`**

```python
from fasthtml.common import *
import config


def home():
    """Defines the home page content."""
    return Div(
        # Hero section
        Div(
            H1(config.APP_NAME,
               cls="text-4xl font-bold text-center text-gray-800 mb-4"),
            P("Create engaging titles for your content with AI assistance.",
              cls="text-xl text-center text-gray-600 mb-6"),
            Div(
                A("Generate Titles \u2192",
                  href="/title-generator",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                cls="flex justify-center"
            ),
            cls="py-12"
        ),

        # Features section
        Div(
            H2("Features", cls="text-3xl font-bold text-center mb-8"),
            Div(
                Div(
                    H3("Platform-Specific", cls="text-xl font-semibold mb-2"),
                    P("Generate titles optimized for blogs, YouTube, social media, and more.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md"
                ),
                Div(
                    H3("Multiple Styles", cls="text-xl font-semibold mb-2"),
                    P("Choose from professional, casual, clickbait, or informative styles.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md"
                ),
                Div(
                    H3("AI-Powered", cls="text-xl font-semibold mb-2"),
                    P("Utilizes advanced AI models to craft engaging, relevant titles.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md"
                ),
                cls="grid grid-cols-1 md:grid-cols-3 gap-6"
            ),
            cls="py-8"
        ),

        # How it works section
        Div(
            H2("How It Works", cls="text-3xl font-bold text-center mb-8"),
            Div(
                Div(
                    Div("1",
                        cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"),
                    H3("Enter Your Topic", cls="text-xl font-semibold mb-2"),
                    P("Describe what your content is about in detail.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md"
                ),
                Div(
                    Div("2",
                        cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"),
                    H3("Choose Settings", cls="text-xl font-semibold mb-2"),
                    P("Select the platform and style that matches your needs.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md"
                ),
                Div(
                    Div("3",
                        cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"),
                    H3("Get Results", cls="text-xl font-semibold mb-2"),
                    P("Review multiple title options and choose your favorite.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md"
                ),
                cls="grid grid-cols-1 md:grid-cols-3 gap-6"
            ),
            cls="py-8"
        )
    )
```

**Archivo: `pages/title_generator.py`**

```python
from fasthtml.common import *


def title_generator_form():
    """Defines the title generator form page."""
    return Div(
        H1("AI Title Generator", cls="text-3xl font-bold text-gray-800 mb-6"),

        Div(
            Form(
                # Topic field
                Div(
                    Label("What's your content about?", fr="topic",
                          cls="block text-gray-700 mb-2"),
                    Textarea(
                        id="topic",
                        name="topic",
                        placeholder="Describe your content topic in detail for better results...",
                        rows=3,
                        required=True,
                        cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
                    ),
                    cls="mb-4"
                ),

                # Platform selection
                Div(
                    Label("Platform:", fr="platform", cls="block text-gray-700 mb-2"),
                    Select(
                        Option("Blog", value="Blog", selected=True),
                        Option("YouTube", value="YouTube"),
                        Option("Social Media", value="Social Media"),
                        Option("Email Subject", value="Email Subject"),
                        Option("News Article", value="News Article"),
                        id="platform",
                        name="platform",
                        cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
                    ),
                    cls="mb-4"
                ),

                # Style selection
                Div(
                    Label("Style:", fr="style", cls="block text-gray-700 mb-2"),
                    Select(
                        Option("Professional", value="Professional", selected=True),
                        Option("Casual", value="Casual"),
                        Option("Clickbait", value="Clickbait"),
                        Option("Informative", value="Informative"),
                        Option("Funny", value="Funny"),
                        id="style",
                        name="style",
                        cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
                    ),
                    cls="mb-4"
                ),

                # Number of titles
                Div(
                    Label("Number of titles:", fr="number_of_titles",
                          cls="block text-gray-700 mb-2"),
                    Select(
                        Option("5", value="5", selected=True),
                        Option("10", value="10"),
                        Option("15", value="15"),
                        id="number_of_titles",
                        name="number_of_titles",
                        cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
                    ),
                    cls="mb-6"
                ),

                # Submit button
                Button(
                    "Generate Titles",
                    type="submit",
                    cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
                ),

                action="/title-generator/generate",
                method="post",
                cls="bg-white p-6 rounded-lg shadow-md mb-8"
            ),

            # Tips section
            Div(
                H3("Tips for Better Titles", cls="text-xl font-semibold mb-2"),
                Ul(
                    Li("Be specific about your topic for more relevant titles", cls="mb-1"),
                    Li("Include your target audience for better context", cls="mb-1"),
                    Li("Mention key points you want to highlight", cls="mb-1"),
                    Li("For YouTube, specify if it's a tutorial, review, etc.", cls="mb-1"),
                    cls="list-disc pl-5 text-gray-600"
                ),
                cls="bg-blue-50 p-4 rounded-lg mt-6"
            ),

            cls="max-w-2xl mx-auto"
        )
    )


def title_generator_results(topic, platform, style, titles):
    """Defines the title generator results page."""
    title_items = []
    for i, title in enumerate(titles):
        title_items.append(
            Li(
                Div(
                    P(title, cls="font-medium"),
                    Button(
                        "Copy",
                        type="button",
                        data_title=title,
                        onclick="navigator.clipboard.writeText(this.dataset.title); this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000);",
                        cls="ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded"
                    ),
                    cls="flex justify-between items-center"
                ),
                cls="p-3 border-b last:border-b-0"
            )
        )

    return Div(
        H1("Generated Titles", cls="text-3xl font-bold text-gray-800 mb-6"),

        Div(
            # Query summary
            Div(
                H2("Your Request", cls="text-xl font-semibold mb-2"),
                P(
                    Strong("Topic: "), Span(topic), Br(),
                    Strong("Platform: "), Span(platform), Br(),
                    Strong("Style: "), Span(style),
                    cls="text-gray-600 mb-4"
                ),
                cls="mb-6"
            ),

            # Titles list
            Div(
                H2("Title Options", cls="text-xl font-semibold mb-2"),
                P("Click 'Copy' to copy any title to your clipboard.", cls="text-gray-600 mb-3"),
                Ul(*title_items, cls="border rounded divide-y"),
                cls="mb-6"
            ),

            # Action buttons
            Div(
                A("Generate More",
                  href="/title-generator",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3"),
                A("Back to Home",
                  href="/",
                  cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"),
                cls="flex"
            ),

            cls="bg-white p-6 rounded-lg shadow-md mb-8 max-w-2xl mx-auto"
        )
    )
```

<Notice type="warning" title="Corrección de seguridad del botón Copiar">
El botón de copiar original usaba JavaScript en línea con interpolación de cadenas (`title.replace("'", "\\'")`), que se rompe con títulos que contienen comillas, barras invertidas o saltos de línea, y es un riesgo de inyección con texto generado por IA. La versión actualizada guarda el título en un atributo `data-title` y lo lee con `this.dataset.title`, lo cual es seguro sin importar el contenido del título.
</Notice>

**Verifica:** Abre `http://localhost:5001/title-generator` en tu navegador y confirma que el formulario se renderiza con todas las etiquetas visibles y los desplegables funcionando. Haz clic en cada etiqueta: el campo correspondiente debería recibir el foco.

### Paso 6: creación de la aplicación principal

**Archivo: `main.py`**

```python
import logging
from fasthtml.common import *

from pages.home import home as home_page
from pages.title_generator import title_generator_form, title_generator_results
from components.page_layout import page_layout
from tools.title_generator import TitleGenerator
import config

# Set up logging
logging.basicConfig(level=logging.DEBUG if config.DEBUG else logging.INFO)
logger = logging.getLogger(__name__)

app = FastHTML()
title_generator = TitleGenerator()


@app.get("/")
def home():
    """Handler for the home page route."""
    return page_layout(
        title=f"Home - {config.APP_NAME}",
        content=home_page(),
        current_page="/"
    )


@app.get("/title-generator")
def title_generator_page():
    """Handler for the title generator page route."""
    return page_layout(
        title=f"Title Generator - {config.APP_NAME}",
        content=title_generator_form(),
        current_page="/title-generator"
    )


@app.post("/title-generator/generate")
async def generate_titles(topic: str, platform: str, style: str, number_of_titles: str):
    """Handler for processing title generation requests."""
    try:
        if not topic:
            error_message = Div(
                H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
                P("Please provide a topic for your titles.", cls="mb-4"),
                A("Try Again", href="/title-generator",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
            )
            return page_layout(
                title=f"Error - {config.APP_NAME}",
                content=error_message,
                current_page="/title-generator"
            )

        num_titles = int(number_of_titles)

        titles = await title_generator.generate_titles(
            topic=topic,
            platform=platform,
            style=style,
            number_of_titles=num_titles,
        )

        return page_layout(
            title=f"Generated Titles - {config.APP_NAME}",
            content=title_generator_results(
                topic=topic,
                platform=platform,
                style=style,
                titles=titles,
            ),
            current_page="/title-generator"
        )
    except ValueError:
        error_message = Div(
            H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
            P("Invalid number of titles. Please select a valid option.", cls="mb-4"),
            A("Try Again", href="/title-generator",
              cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
            cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
        )
        return page_layout(
            title=f"Error - {config.APP_NAME}",
            content=error_message,
            current_page="/title-generator"
        )
    except Exception as e:
        logger.exception("Title generation failed")
        error_message = Div(
            H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
            P("Something went wrong while generating titles. Please try again.", cls="mb-4"),
            A("Try Again", href="/title-generator",
              cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
            cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
        )
        return page_layout(
            title=f"Error - {config.APP_NAME}",
            content=error_message,
            current_page="/title-generator"
        )


@app.get("/{path:path}")
def not_found(path: str):
    """Handler for 404 Not Found errors."""
    error_content = Div(
        H1("404 - Page Not Found", cls="text-3xl font-bold text-gray-800 mb-4"),
        P(f"Sorry, the page '/{path}' does not exist.", cls="mb-4"),
        A("Return Home", href="/",
          cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
        cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md text-center"
    )
    return page_layout(
        title=f"404 Not Found - {config.APP_NAME}",
        content=error_content,
        current_page="/"
    )


if __name__ == "__main__":
    serve()
```

Cambios clave respecto al `main.py` original:

- **`serve()` en lugar de `uvicorn.run()`** — el `serve()` integrado de FastHTML detecta la app automáticamente, usa el puerto 5001 por defecto y muestra un enlace clicable. No hace falta `import uvicorn`.
- **El manejador de errores no filtra excepciones** — el código antiguo mostraba `str(e)` a los usuarios, lo que podía exponer claves de API o fragmentos de la pila. Ahora registra el traceback completo en el servidor y muestra un mensaje genérico.
- **Guarda de `ValueError`** — captura las conversiones fallidas de `int(number_of_titles)` por separado del resto de errores.

**Verifica:** Arranca la app y confirma que inicia sin errores:

```bash
python main.py
# Should print something like: Link: http://0.0.0.0:5001
```

**Si falla:**
- `Port 5001 already in use` — mata el otro proceso (`lsof -i :5001`) o define `PORT=8000` en tu `.env`
- `ImportError` — comprueba que todos los archivos están en los directorios correctos y que existen los archivos `__init__.py`

### Paso 7: ejecutar y probar tu aplicación

Prueba el flujo completo:

```bash
# 1. Start the app
python main.py

# 2. Test home page (in another terminal)
curl -s http://localhost:5001/ | head -20
# Should return HTML with "AI Title Generator"

# 3. Test form page
curl -s http://localhost:5001/title-generator | grep "Generate Titles"
# Should show the form button text

# 4. Test generation (form POST)
curl -s -X POST http://localhost:5001/title-generator/generate \
  -d "topic=Python+web+frameworks&platform=Blog&style=Professional&number_of_titles=5" \
  | grep "Title Options"
# Should return results with "Title Options" heading
```

En el navegador, visita `http://localhost:5001`, ve al Title Generator, escribe un tema y haz clic en «Generate Titles». Deberías ver una lista de títulos con botones de copiar.

<Accordion label="401 Unauthorized — clave de API no válida" group="troubleshooting">
Revisa tu archivo `.env`. Asegúrate de que `OPENROUTER_API_KEY` está definida sin espacios ni comillas al final. Verifica que la clave funciona en [openrouter.ai/keys](https://openrouter.ai/keys).
</Accordion>

<Accordion label="402 Créditos insuficientes" group="troubleshooting">
Tu cuenta de OpenRouter se quedó sin créditos. Añade crédito en [openrouter.ai/credits](https://openrouter.ai/credits), o cambia a un modelo gratuito en tu `.env`:
```
DEFAULT_MODEL=meta-llama/llama-3.1-8b-instruct:free
```
</Accordion>

<Accordion label="404 Modelo no encontrado" group="troubleshooting">
El slug del modelo no coincide con ninguno disponible en OpenRouter. Asegúrate de que usa el formato `provider/model` (p. ej., `openai/gpt-4o-mini`). Explora los modelos disponibles en [openrouter.ai/models](https://openrouter.ai/models).
</Accordion>

<Accordion label="Tiempo de espera agotado" group="troubleshooting">
El modelo tardó demasiado en responder. Esto puede pasar con los modelos gratuitos en horas punta. PydanticAI tiene reintentos integrados: si persiste, prueba otro modelo o aumenta el timeout con `ModelSettings(timeout=60)`.
</Accordion>

<Accordion label="ValidationError de PydanticAI" group="troubleshooting">
El modelo devolvió una respuesta que no coincide con `TitleGenerationResponse`. PydanticAI reintenta automáticamente (hasta 3 veces por defecto). Si sigue fallando, puede que el modelo no soporte bien la salida estructurada: prueba con otro modelo como `openai/gpt-4o-mini`.
</Accordion>

## Mejorar el generador de títulos

Estas son las mejores formas de ampliar este proyecto:

1. **Modelos de salida más ricos** — Añade campos a `TitleGenerationResponse` como `description: str`, `keywords: list[str]` o `seo_score: float`. PydanticAI los valida todos automáticamente.

2. **Más plataformas** — Amplía a TikTok, Pinterest, LinkedIn o Substack. Actualiza el desplegable de plataformas y ajusta el system prompt con indicaciones específicas por plataforma.

3. **Variaciones de títulos** — Añade una función para generar variaciones de un título existente para pruebas A/B.

4. **Favoritos e historial** — Guarda los títulos generados para que los usuarios puedan consultarlos. Puedes [añadir una base de datos SQLite](/es/fasthtml-sqlite-db/) para persistir el historial de generación.

5. **Exportar a CSV** — Permite que los usuarios descarguen sus títulos generados como archivo para uso por lotes.

## Notas de producción: coste, seguridad y despliegue

Antes de exponer esta app más allá de localhost, esto es lo que debes considerar.

### Desglose de costes

<Notice type="info" title="Coste por petición">
Una generación típica de 5 títulos usa ~500–1.000 tokens en total. Con el precio de gpt-4o-mini (~$0.15/$0.60 por 1M de tokens vía OpenRouter), eso es menos de $0.001 por generación. Los modelos gratuitos de OpenRouter están disponibles pero tienen límites de uso estrictos (~50 solicitudes/día).
</Notice>

Puedes registrar el uso de tokens en tu código mediante `result.usage` (una propiedad del resultado de la ejecución del agente). Ten en cuenta que el endpoint POST público no tiene autenticación: cualquiera que pueda alcanzarlo puede gastar tus créditos. Añade limitación de tasa o autenticación antes de exponerlo públicamente.

### Desarrollo vs. producción

`serve()` con los valores por defecto se ejecuta con `reload=True`: eso es solo para desarrollo. Para producción:

- Usa `serve(reload=False)` o un gestor de procesos
- Pon un proxy inverso (Caddy o nginx) delante para TLS y archivos estáticos
- Tailwind vía CDN está bien en desarrollo; usa un paso de compilación en producción
- Considera Docker para despliegues consistentes

<Button text="Desplegar con Docker \u2192" link="/docker-run-python/" variant="outline" color="blue" size="sm" icon="arrow-right" />

Para alojamiento VPS económico donde desplegar tu app, [Hetzner Cloud](https://go.bitdoze.com/hetzner) parte de ~4 EUR/mes con un rendimiento sólido. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) es otra opción económica con almacenamiento NVMe.

_Los precios de los VPS subieron en general en 2026: si estás reconsiderando un servidor alquilado, mira [qué cambió y cuándo gana un mini PC](/es/subidas-precios-vps/)._

### Lista de verificación de seguridad

<ListCheck>
**Antes de lanzar:**
- `.env` está en `.gitignore` y nunca se sube al repositorio
- El manejador de errores registra en el servidor pero no expone claves de API ni trazas a los usuarios
- El botón de copiar usa el atributo `data-title`: seguro frente a inyección de contenido generado por IA
- Añade limitación de tasa o autenticación antes de exponer el endpoint POST públicamente
- Usa `serve(reload=False)` en producción
- Define `DEBUG=False` en el `.env` de producción
</ListCheck>

## Ideas avanzadas de implementación

Para quienes quieran llevar este proyecto más lejos:

1. **Autenticación de usuarios** — Añade inicio de sesión para que los usuarios guarden preferencias e historial de títulos. [Añade autenticación de usuarios y controles de administración](/es/fasthtml-autenticacion-usuarios/) a tu app.

2. **Sitio de herramientas de IA multipágina** — Extiende esto a un [sitio multipágina de herramientas de IA con FastHTML](/es/fasthtml-herramientas-ia-complejas/) con varios generadores.

3. **Procesamiento por lotes** — Genera títulos para varios temas a la vez.

4. **Integración de pruebas A/B** — Conecta con plataformas de analítica para probar la efectividad de los títulos.

Si te interesan enfoques alternativos para agentes de IA, mira [cómo construir un agente de IA con Mastra](/es/crear-agente-ia-mastra/) o [crea un bot de Discord con IA usando Agno](/es/crear-tu-propio-agente-ia/).

Para apps FastHTML más grandes, revisa `fast_app()` con enrutamiento basado en nombres de funciones (`rt`) y [MonsterUI](https://monsterui.answer.ai/) como biblioteca de componentes recomendada.

## Conclusión

Has construido un generador de títulos completo potenciado por IA usando FastHTML y PydanticAI. Las conclusiones clave:

- **Desarrollo web solo con Python** — sin frameworks de JavaScript para una UI funcional y responsive
- **Salida de IA estructurada** — PydanticAI valida las respuestas contra tu modelo Pydantic, eliminando el frágil análisis con regex
- **Integración con OpenRouter** — acceso a modelos independiente del proveedor con soporte nativo de PydanticAI
- **Arquitectura modular** — separación limpia de responsabilidades que escala a herramientas más complejas
- **Respuestas validadas y con tipado seguro** — la salida de la IA tiene garantizada la coincidencia con tu esquema

El patrón de aquí —FastHTML para la UI, PydanticAI para llamadas de IA estructuradas, OpenRouter para el acceso a modelos— funciona para muchas otras herramientas con IA: resumidores de contenido, generadores de descripciones de producto, redactores de borradores de email, optimizadores SEO. El generador de títulos es un punto de partida que puedes adaptar.

En el próximo artículo [ampliaremos esto a un sitio de herramientas de IA multipágina](/es/fasthtml-herramientas-ia-complejas/) con varios generadores. También puedes [añadir una base de datos SQLite](/es/fasthtml-sqlite-db/) para guardar el historial de generación, o [añadir autenticación de usuarios y controles de administración](/es/fasthtml-autenticacion-usuarios/).

## Serie FastHTML

Estos son todos los artículos de nuestra serie de FastHTML:

- [FastHTML para principiantes: crea una UI para tu app Python](/es/empezar-fasthtml/)
- [Sitio web multipágina con FastHTML: tutorial completo de estructura](/es/fasthtml-multiples-paginas/)
- [FastHTML + PydanticAI: crea una aplicación web de IA en Python](/es/fasthtml-herramientas-pydanticai/) (este artículo)
- [Herramientas de IA con FastHTML: guía para construir un sitio multipágina](/es/fasthtml-herramientas-ia-complejas/)
- [Cómo añadir una base de datos SQLite a tu app FastHTML](/es/fasthtml-sqlite-db/)
- [Cómo añadir autenticación de usuarios y controles de administración a tu generador de títulos con IA en FastHTML](/es/fasthtml-autenticacion-usuarios/)