FastHTML + PydanticAI: crea una aplicación web de IA en Python
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.
Actualizado Publicado 38 min de lectura

FastHTML Tutorial Series
Part 3 of 6
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, donde cubrimos lo básico, y sigue con nuestro tutorial de estructura FastHTML multipágina. ¿Nuevo en la integración de IA? Lee nuestra guía para principiantes de programación con IA. ¿Quieres saber cómo se compara FastHTML con otras opciones? Mira nuestro resumen de los mejores frameworks web de Python.
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.
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.
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
Resumen de la estructura del proyecto
Esta es la estructura exacta que vamos a construir:
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 toolEsta estructura sigue el mismo patrón modular del tutorial multipágina. 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:
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]"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.
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 para ver el catálogo completo.
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.0Ahora crea el archivo de configuración:
Archivo: config.py
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=TrueVerifica: Confirma que las dependencias están instaladas y los imports funcionan:
pip list | grep pydantic-ai
# Should show pydantic-ai >= 2.0
python -c "from pydantic_ai.models.openrouter import OpenRouterModel; print('OK')"
# Should print: OKSi falla:
ModuleNotFoundError: No module named 'pydantic_ai.models.openrouter'— instalastepydantic-aisin 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
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.outputCompara 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.
OpenRouter (recomendado)
El código anterior usa el proveedor nativo de OpenRouter de PydanticAI. Instálalo con:
pip install "pydantic-ai[openrouter]"Esta es la vía recomendada. No hace falta configurar el cliente manualmente.
Compatible con OpenAI directo
Si no quieres el extra [openrouter], puedes usar la alternativa compatible con OpenAI:
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.
¿Por qué salida estructurada en lugar de chat sin procesar?
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.
Verifica: Confirma que el módulo se importa sin problemas:
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
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]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.
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
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
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
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"
)
)
)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.
Nota: Tailwind CSS vía CDN está bien para desarrollo y prototipos. Para producción, usa un paso de compilación o considera MonsterUI 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
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
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"
)
)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.
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
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 deuvicorn.run()— elserve()integrado de FastHTML detecta la app automáticamente, usa el puerto 5001 por defecto y muestra un enlace clicable. No hace faltaimport 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 deint(number_of_titles)por separado del resto de errores.
Verifica: Arranca la app y confirma que inicia sin errores:
python main.py
# Should print something like: Link: http://0.0.0.0:5001Si falla:
Port 5001 already in use— mata el otro proceso (lsof -i :5001) o definePORT=8000en tu.envImportError— 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:
# 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" headingEn 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.
401 Unauthorized — clave de API no válida
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.
402 Créditos insuficientes
Tu cuenta de OpenRouter se quedó sin créditos. Añade crédito en openrouter.ai/credits, o cambia a un modelo gratuito en tu .env:
DEFAULT_MODEL=meta-llama/llama-3.1-8b-instruct:free404 Modelo no encontrado
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.
Tiempo de espera agotado
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).
ValidationError de PydanticAI
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.
Mejorar el generador de títulos
Estas son las mejores formas de ampliar este proyecto:
-
Modelos de salida más ricos — Añade campos a
TitleGenerationResponsecomodescription: str,keywords: list[str]oseo_score: float. PydanticAI los valida todos automáticamente. -
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.
-
Variaciones de títulos — Añade una función para generar variaciones de un título existente para pruebas A/B.
-
Favoritos e historial — Guarda los títulos generados para que los usuarios puedan consultarlos. Puedes añadir una base de datos SQLite para persistir el historial de generación.
-
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
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).
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
Para alojamiento VPS económico donde desplegar tu app, Hetzner Cloud parte de ~4 EUR/mes con un rendimiento sólido. 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.
Lista de verificación de seguridad
Antes de lanzar:
.envestá en.gitignorey 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=Falseen el.envde producción
Ideas avanzadas de implementación
Para quienes quieran llevar este proyecto más lejos:
-
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 a tu app.
-
Sitio de herramientas de IA multipágina — Extiende esto a un sitio multipágina de herramientas de IA con FastHTML con varios generadores.
-
Procesamiento por lotes — Genera títulos para varios temas a la vez.
-
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 o crea un bot de Discord con IA usando Agno.
Para apps FastHTML más grandes, revisa fast_app() con enrutamiento basado en nombres de funciones (rt) y MonsterUI 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 con varios generadores. También puedes añadir una base de datos SQLite para guardar el historial de generación, o añadir autenticación de usuarios y controles de administración.
Serie FastHTML
Estos son todos los artículos de nuestra serie de FastHTML:
- FastHTML para principiantes: crea una UI para tu app Python
- Sitio web multipágina con FastHTML: tutorial completo de estructura
- FastHTML + PydanticAI: crea una aplicación web de IA en Python (este artículo)
- Herramientas de IA con FastHTML: guía para construir un sitio multipágina
- Cómo añadir una base de datos SQLite a tu app FastHTML
- Cómo añadir autenticación de usuarios y controles de administración a tu generador de títulos con IA en FastHTML


