Cerebro Digital con Python y Orquestación de Agentes con CrewAI

- Andrés Cruz -

Video thumbnail

En este post, analizaremos el proceso de diseño y desarrollo de un "cerebro digital", un sistema concebido para actuar como un asistente avanzado en la generación automatizada de contenidos, específicamente enfocado en la creación de presentaciones estructurales. Este proyecto debe abordarse bajo la filosofía de una bitácora de desarrollo evolutiva, lo que implica que la arquitectura pasa por múltiples fases experimentales y refactorizaciones continuas antes de consolidar una versión óptima para producción.

Filosofía del Entorno: Privacidad y Ejecución Local

El pilar de este cerebro digital es la ejecución 100% local. Priorizar que los Modelos de Lenguaje Grande (LLM) se ejecuten localmente elimina la dependencia de APIs de terceros, garantiza la privacidad absoluta de los datos procesados y suprime los costos operativos variables asociados al consumo de tokens externos.

Para la fase inicial de pruebas, se integró el framework Hermes a través de una interfaz de chat conversacional para evaluar la viabilidad del código generado. Sin embargo, para mitigar el ruido sintáctico y la rigidez de depender de herramientas externas, el flujo evolucionó hacia una implementación backend completamente personalizada utilizando el ecosistema de Python.

Estructura del Backend con FastAPI

Python es el lenguaje estándar en el ecosistema de la Inteligencia Artificial, lo que fundamenta su elección para este proyecto. Como framework de desarrollo web se seleccionó FastAPI, debido a su alto rendimiento, soporte nativo para operaciones asíncronas y su capacidad para estructurar servicios modulares basados en APIs de manera limpia y escalable.

La arquitectura del backend se organiza bajo la siguiente estructura de directorios y responsabilidades:

  • Core / Models: Definición de las estructuras de datos y configuraciones base del sistema.
  • Agents: Módulos encargados de la lógica de los agentes de Inteligencia Artificial.
  • API / Endpoints: Rutas expuestas (como el endpoint /generate) para comunicar la interfaz de usuario con los servicios internos.
brain-app/
├── app/
│   ├── __init__.py
│   ├── main.py            # Punto de entrada de FastAPI
│   ├── core/              # Configuración y conexión a Ollama/APIs
│   │   └── config.py
│   ├── agents/            # Aquí vivirán mis scripts de CrewAI (JSON, HTML, Flux)
│   │   └── __init__.py
│   ├── tools/             # Skills personalizadas de Python (guardar archivos, etc.)
│   │   └── __init__.py
│   ├── templates/         # Vistas HTML (Frontend del tablero)
│   │   └── index.html
│   └── static/            # CSS y JS estáticos
│       ├── css/
│       └── js/
├── requirements.txt       # Dependencias (fastapi, uvicorn, jinja2, crewai)
└── .env                   # Variables de entorno (URLs de Ollama, Flux, etc.)

 

Orquestación de Agentes con CrewAI

Para coordinar el comportamiento de los modelos locales, se implementó CrewAI, un framework diseñado específicamente para la orquestación de agentes de inteligencia artificial. CrewAI permite definir roles, asignar herramientas (skills) y encadenar tareas de forma secuencial.

En el primer diseño experimental, la generación se dividió en dos agentes secuenciales utilizando un modelo de la familia Llama ejecutado localmente:

  1. Agente de Contenido: Recibe el tópico del usuario (por ejemplo, "Sistemas de enrutamiento en Laravel"), analiza el tema y genera la información estructurada en un formato de intercambio de datos (JSON).
  2. Agente de Maquetación: Toma el JSON producido por el agente anterior y se encarga de estructurar el código HTML y CSS final de la presentación, aplicando estilos y paletas de colores específicas.

Para evitar la saturación del contexto del modelo, el flujo se configuró en modo secuencial y detallado (verbose=True) para auditar el comportamiento de los agentes directamente en la consola del servidor.

Los agentes con Crew:

app/agents/slides_crew.py

from crewai import LLM, Agent, Crew, Process, Task

# =====================================================================
# 1. CONEXIÓN DIRECTA A TU OLLAMA LOCAL (127.0.0.1)
# =====================================================================
# Llama 3 8B para el procesamiento lógico y estructuración del JSON
llm_json = LLM(
    # model="ollama/llama3-default",
    model="ollama/gemma3:12b",
    base_url="http://127.0.0.1:11434"
)

# Gemma 4 12B para la maquetación visual UI/UX premium en HTML
llm_html = LLM(
    # model="ollama/gemma4-slides",
    model="ollama/gemma3:12b",
    base_url="http://127.0.0.1:11434"
)

# =====================================================================
# 2. DEFINICIÓN DE AGENTES ATÓMICOS (Sin herramientas heredadas)
# =====================================================================
arquitecto_json = Agent(
    role='Arquitecto de Contenido y Datos JSON',
    goal='Sintetizar temas complejos de desarrollo en un formato JSON estructurado rígido.',
    backstory='Eres un desarrollador backend senior meticuloso. Tu único trabajo es crear la estructura de datos sin preocuparte por el diseño visual.',
    verbose=True,
    llm=llm_json
)

disenador_html = Agent(
    role='Desarrollador Frontend UI/UX Senior',
    goal='Tomar estructuras de datos JSON e inyectarlas en plantillas HTML/CSS oscuras interactivas.',
    backstory='Eres un diseñador web experto en modo oscuro. Sigues estrictamente la paleta oscura (#0f172a), textos blancos, detalles cian y bordes redondeados (12px).',
    verbose=True,
    llm=llm_html
)

# =====================================================================
# 3. FUNCIÓN DE ORQUESTACIÓN PARA TU BACKEND
# =====================================================================
def ejecutar_pipeline_slides(tema: str, ruta_salida: str = "app/static/presentacion.html") -> str:
    """
    Función que recibe un tema, ejecuta la tubería de agentes locales
    y guarda el resultado final en la carpeta estática de tu app FastAPI.
    """

    # Tarea 1: Generar el formato de intercambio (JSON)
    tarea_json = Task(
        description=(
            f"Analiza el tema: '{tema}'. "
            "Crea una presentación de 4 diapositivas estructuradas técnicamente. "
            "Devuelve ÚNICAMENTE un objeto JSON con esta estructura exacta:\n"
            "{{\n"
            "  \"titulo_general\": \"...\",\n"
            "  \"slides\": [\n"
            "    {{\"slide\": 1, \"titulo\": \"...\", \"puntos\": [\"...\", \"...\"]}},\n"
            "    ... \n"
            "  ]\n"
            "}}\n"
            "⚠️ REGLA CRÍTICA: No incluyas bloques de código Markdown (```json), saludos ni texto extra. Solo el string JSON puro."
        ),
        expected_output="Un string JSON válido y limpio.",
        agent=arquitecto_json
    )

    # Tarea 2: Consumir el JSON y renderizar el frontend interactivo
    tarea_html = Task(
        description=(
            "Toma el JSON generado en la tarea anterior. "
            "Genera una estructura de presentación HTML interactiva e independiente.\n\n"
            "Reglas visuales estrictas:\n"
            "- Fondo del body: #0f172a\n"
            "- Tarjetas de las diapositivas: fondo #1e1e24, padding amplio, border-radius de 12px.\n"
            "- Tipografía: Texto en blanco (#ffffff) y destacados técnicos en cian (#22d3ee).\n"
            "- Agrega un script simple (<script>) para pasar de diapositiva usando las flechas del teclado."
        ),
        expected_output="Código HTML completo, semántico y listo para producción.",
        agent=disenador_html,
        output_file=ruta_salida  # Escribe el archivo directo en tu static de FastAPI
    )

    # El Cerebro que secuencia el proceso
    crew = Crew(
        agents=[arquitecto_json, disenador_html],
        tasks=[tarea_json, tarea_html],
        process=Process.sequential, # Secuencial estricto: Tarea 2 depende de la Tarea 1
        verbose=True
    )

    # Ejecutamos pasando el parámetro dinámico
    #crew.kickoff_async(inputs={'tema': tema})
    crew.kickoff(inputs={'tema': tema})
    return ruta_salida

El formulario:

app/api/slides.py

from __future__ import annotations

import os

from fastapi import APIRouter, Form, HTTPException
from fastapi.responses import RedirectResponse

from app.agents.slides_crew import ejecutar_pipeline_slides

router = APIRouter()


@router.post("/generar", name="api_slides_generate")
def generar_presentacion(tema: str = Form(...)) -> RedirectResponse:
    try:
        os.makedirs("app/static", exist_ok=True)
        print(f"Slides Router: Activando agentes locales para: {tema}")

        ejecutar_pipeline_slides(tema=tema)

        return RedirectResponse(url="/static/presentacion.html", status_code=303)
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=f"Error en los agentes de slides: {str(e)}",
        ) from e

Y la presentación de la vista con GET:

app/routers/pages.py

@router.get("/agents/slides", response_class=HTMLResponse, name="agents_slides")
async def slides_form(request: Request) -> HTMLResponse:
    return templates.TemplateResponse(
        request=request,
        name="agents/slides.html",
        context={"page_title": "Generate Slides"},
    )

Su template:

{% extends "base.html" %}

{% block content %}
<div class="max-w-2xl mx-auto">
    <div class="mb-8">
        <h1 class="text-2xl font-bold text-gray-900">Generate Presentation</h1>
        <p class="text-gray-500 mt-1">
            Describe the topic and the AI agent pipeline will create a complete HTML slideshow.
        </p>
    </div>

    <form action="{{ url_for('api_slides_generate') }}" method="POST" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
        <div class="mb-4">
            <label for="tema" class="block text-sm font-medium text-gray-700 mb-2">
                Presentation Topic
            </label>
            <textarea
                id="tema"
                name="tema"
                rows="5"
                required
                placeholder="e.g. Introduccion a la inteligencia artificial con Python..."
                class="w-full px-4 py-3 rounded-lg border border-gray-300 text-sm placeholder-gray-400
                       focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
                       transition resize-y"
            ></textarea>
            <p class="mt-2 text-xs text-gray-400">
                The agents will structure the content, generate JSON, and build a dark-themed HTML presentation.
            </p>
        </div>

        <div class="flex items-center gap-3">
            <button
                type="submit"
                id="submit-btn"
                class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium
                       hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2
                       transition disabled:opacity-50 disabled:cursor-not-allowed"
            >
                <svg id="spinner" class="w-4 h-4 hidden animate-spin" fill="none" viewBox="0 0 24 24">
                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
                </svg>
                <span id="btn-text">Generate</span>
            </button>
            <a href="{{ url_for('agents_list') }}" class="text-sm text-gray-500 hover:text-gray-700 transition">Cancel</a>
        </div>
    </form>

    <div class="mt-8 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
        <h2 class="text-sm font-semibold text-gray-900 uppercase tracking-wide mb-3">Pipeline</h2>
        <div class="space-y-3">
            <div class="flex items-start gap-3">
                <div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 mt-0.5">
                    <span class="text-xs font-bold text-indigo-600">1</span>
                </div>
                <div>
                    <p class="text-sm font-medium text-gray-900">JSON Structurer Agent</p>
                    <p class="text-xs text-gray-500">Llama 3 — organizes the topic into a rigid JSON outline.</p>
                </div>
            </div>
            <div class="flex items-start gap-3">
                <div class="w-6 h-6 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5">
                    <span class="text-xs font-bold text-emerald-600">2</span>
                </div>
                <div>
                    <p class="text-sm font-medium text-gray-900">HTML Designer Agent</p>
                    <p class="text-xs text-gray-500">Gemma 4 — injects JSON into a dark-themed interactive HTML template.</p>
                </div>
            </div>
            <div class="flex items-start gap-3">
                <div class="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0 mt-0.5">
                    <span class="text-xs font-bold text-amber-600">3</span>
                </div>
                <div>
                    <p class="text-sm font-medium text-gray-900">Output</p>
                    <p class="text-xs text-gray-500">Saved to <code class="text-amber-700 bg-amber-50 px-1 rounded">/static/presentacion.html</code></p>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
    document.addEventListener("DOMContentLoaded", function () {
        var form = document.querySelector("form");
        var btn = document.getElementById("submit-btn");
        var spinner = document.getElementById("spinner");
        var btnText = document.getElementById("btn-text");

        if (form && btn) {
            form.addEventListener("submit", function () {
                btn.disabled = true;
                spinner.classList.remove("hidden");
                btnText.textContent = "Generating...";
            });
        }
    });
</script>
{% endblock %}

El Problema de la Estructura Variante y el No-Determinismo

La primera aproximación que hicimos antes, el uso de doble agente reveló una limitación crítica ligada a la naturaleza de los LLM. La Inteligencia Artificial no es determinista, sino probabilística. Esto significa que, ante una misma instrucción exacta, el segundo agente generaba estructuras HTML inconsistentes, variables y propensas a errores de renderizado.

Adicionalmente, las respuestas de los modelos locales suelen incluir delimitadores de texto adicionales o bloques de código mal formados, lo que rompe el parseo estricto del JSON e introduce inestabilidad en el hilo principal de ejecución, especialmente si la petición se procesa de forma síncrona sin delegar el flujo a JavaScript en el cliente.

Refactorización de la Arquitectura: Hacia un Enfoque Híbrido

Para solucionar la inconsistencia estética y estructural (HTML Y CSS), se rediseñó el flujo eliminando por completo al segundo agente. En su lugar, se implementó un enfoque híbrido que combina la capacidad analítica de la IA con la rigidez del desarrollo programático tradicional.

El Nuevo Flujo de Trabajo Operativo

El sistema actual opera bajo un proceso optimizado de dos pasos que introduce una capa de validación humana intermedia:

  1. Paso 1: Extracción Estructurada (IA): Un único agente de CrewAI procesa el tema y devuelve exclusivamente un esquema JSON limpio que contiene el texto de las diapositivas.
  2. Paso 2: Edición e Inyección en Plantilla (Programático): El backend de FastAPI recibe este JSON y lo despliega en un formulario web interactivo. El usuario puede auditar, corregir o expandir el texto directamente (por ejemplo, cambiar el título a "Rutas en Laravel 13"). Una vez aprobado, Python toma estos datos corregidos y los inyecta mediante código estructurado dentro de una plantilla HTML fija y predefinida.

app/agents/slides_crew.py

# =====================================================================
# FASE 1: Generar la propuesta limpia en JSON
# =====================================================================
def generar_estructura_json(tema: str) -> Any:
    tarea_json = Task(
        description=(
            f"Analiza el tema: '{tema}'. Crea una estructura de 4 diapositivas técnicas. "
            "Devuelve ÚNICAMENTE un objeto JSON puro con este formato:\n"
            "{{\n"
            '  "titulo_general": "...",\n'
            '  "slides": [\n'
            '    {{"slide": 1, "titulo": "...", "puntos": ["...", "..."]}}\n'
            "  ]\n"
            "}}\n"
            "⚠️ REGLA: No incluyas bloques ```json ni texto extra."
        ),
        expected_output="Un string JSON válido.",
        agent=arquitecto_json,
    )

    # Crew minúscula de un solo agente para la fase 1
    crew_fase1 = Crew(agents=[arquitecto_json], tasks=[tarea_json], verbose=True)
    resultado = crew_fase1.kickoff(inputs={"tema": tema})

    # Limpiamos la respuesta cruda del LLM antes de parsear
    return parsear_json_ia(str(resultado))

Para limpiar el JSON:

utils.py

from __future__ import annotations

import re
from typing import Any


def limpiar_json(texto: str) -> str:
    texto = texto.strip()
    if match := re.search(r"```(?:json)?\s*\n?(.*?)\n?```", texto, re.DOTALL):
        texto = match.group(1).strip()
    if texto.startswith("```json"):
        texto = texto[7:]
    elif texto.startswith("```"):
        texto = texto[3:]
    if texto.endswith("```"):
        texto = texto[:-3]
    inicio = texto.find("{")
    fin = texto.rfind("}")
    if inicio != -1 and fin != -1 and fin > inicio:
        texto = texto[inicio : fin + 1]
    return texto.strip()


def parsear_json_ia(texto: str) -> Any:
    import json

    texto_limpio = limpiar_json(texto)
    return json.loads(texto_limpio)

Ahora, tenemos dos procesos de formularios, uno para preguntar el tema y otro para devolver el JSON con la posibilidad de que el usuario pueda actualizar el mismo y que lo deje justo como necesite:

@router.post("/step1", name="api_slides_step1")
def step1(request: Request, tema: str = Form(...)) -> HTMLResponse:
    try:
        datos_json = generar_estructura_json(tema=tema)
        json_bonito = json.dumps(datos_json, indent=4, ensure_ascii=False)

        return templates.TemplateResponse(
            request=request,
            name="agents/slides/steps.html",
            context={"json_borrador": json_bonito, "tema_actual": tema},
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Fallo en Fase 1: {str(e)}") from e


@router.post("/step2", name="api_slides_step2")
def step2(request: Request, json_editado: str = Form(...)) -> RedirectResponse:
    try:
        os.makedirs("app/static", exist_ok=True)
        
        # 1. Convertimos el string del textarea editado en un diccionario real de Python
        datos_dict = json.loads(json_editado)
        
        # 2. Cargamos el template físico aparte y lo inyectamos con los datos del JSON
        template_slides = templates.get_template("agents/slides/_structure.html")
        html_compilado = template_slides.render(request=request, **datos_dict)
        
        # 3. Guardamos el resultado final en la carpeta static
        ruta_salida = "app/static/presentacion.html"
        with open(ruta_salida, "w", encoding="utf-8") as f:
            f.write(html_compilado)
        
        # Redirección directa al archivo estático recién horneado
        return RedirectResponse(url="/static/presentacion.html", status_code=303)
        
    except json.JSONDecodeError:
        raise HTTPException(status_code=400, detail="El contenido editado no es un JSON válido. Revisa las comas o corchetes.")
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Fallo en Fase 2 (Renderizado): {str(e)}") from e

El template usado por ambos pasos:

{% extends "base.html" %}

{% block content %}
<section class="max-w-2xl mx-auto bg-gray-900 rounded-xl border border-gray-800 p-6 flex flex-col gap-6">

    <form id="step1-form"" action="{{ url_for('api_slides_step1') }}" method="POST" class="flex flex-col gap-3">
        <label class="text-xs font-semibold uppercase text-gray-400">1. Define the Topic</label>
        <input type="text" name="tema" value="{{ tema_actual if tema_actual else '' }}" required
            placeholder="e.g. Arquitectura limpia en Python"
            class="w-full bg-gray-950 border border-gray-800 rounded-lg p-3 text-sm text-white focus:border-cyan-500 outline-none">
        <button type="submit"
            class="w-full bg-gray-800 hover:bg-gray-700 text-cyan-400 font-medium text-sm py-2.5 rounded-lg border border-gray-700 transition-all">
            Propose Structure (JSON)
        </button>
    </form>

    {% if json_borrador %}
    <!-- PASO 2 -->
    <hr class="border-gray-800">

    <div class="flex flex-col gap-3">
        <div class="flex items-center justify-between">
            <label class="text-xs font-semibold uppercase text-gray-400">2. Review & Edit Structure</label>
            <span class="text-[10px] bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-2 py-0.5 rounded">Draft Ready</span>
        </div>

        <p class="text-xs text-gray-500">You can edit the JSON directly below. When ready, click to generate the final HTML presentation.</p>

        <div id="step2-error" class="hidden p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"></div>

        <form id="step2-form" action="{{ url_for('api_slides_step2') }}" method="POST" class="flex flex-col gap-3">
            <textarea name="json_editado" rows="15"
                class="w-full bg-gray-950 border border-gray-800 rounded-lg p-3 font-mono text-xs text-emerald-400 focus:border-emerald-500 outline-none leading-relaxed">{{ json_borrador }}</textarea>

            <button type="submit" id="step2-btn"
                class="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-medium text-sm py-2.5 rounded-lg transition-all
                       shadow-[0_4px_12px_rgba(34,211,238,0.2)] disabled:opacity-50 disabled:cursor-not-allowed">
                <svg id="step2-spinner" class="w-4 h-4 hidden animate-spin inline" fill="none" viewBox="0 0 24 24">
                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
                </svg>
                <span id="step2-btn-text">Approve & Render Final Dashboard</span>
            </button>
        </form>
    </div>
    {% endif %}

</section>

<script>
    document.addEventListener("DOMContentLoaded", function () {
        var form = document.getElementById("step2-form");
        if (!form) return;

        var btn = document.getElementById("step2-btn");
        var spinner = document.getElementById("step2-spinner");
        var btnText = document.getElementById("step2-btn-text");
        var errorEl = document.getElementById("step2-error");

        form.addEventListener("submit", async function (e) {
            e.preventDefault();

            btn.disabled = true;
            spinner.classList.remove("hidden");
            btnText.textContent = "Rendering...";
            if (errorEl) errorEl.classList.add("hidden");

            try {
                var resp = await fetch(form.action, {
                    method: "POST",
                    body: new FormData(form),
                });

                if (!resp.ok) {
                    var data = await resp.json().catch(function () { return {}; });
                    throw new Error(data.detail || "Render failed");
                }

                window.location.href = resp.url;
            } catch (err) {
                if (errorEl) {
                    errorEl.textContent = err.message;
                    errorEl.classList.remove("hidden");
                }
                btn.disabled = false;
                spinner.classList.add("hidden");
                btnText.textContent = "Approve & Render Final Dashboard";
            }
        });
    });
</script>
{% endblock %}

Como puedes ver en el paso dos:

templates.get_template("agents/slides/_structure.html")

Usamos una estructura para de manera programática, tomamos en JSON en INMEDIATAMENTE, generamos la presentación, ahorrándonos el uso de un agente con lo que esto conlleva y ahora SIEMPRE tenemos la misma estructura para la presentación:

<div class="presentation-wrapper">
    {% for slide in slides %}
    <div class="slide-container {% if loop.first %}active{% endif %}" id="slide-{{ loop.index }}">
        <div class="slide-title">{{ slide.titulo }}</div>
        <div class="bullet-list">
            <ul>
                {% for punto in slide.puntos %}
                <li>
                    <i class="fa-solid fa-square-terminal"></i> 
                    {{ punto }}
                </li>
                {% endfor %}
            </ul>
        </div>
    </div>
    {% endfor %}
</div>

Moraleja Arquitectónica

No todo el software debe resolverse utilizando Inteligencia Artificial. Los procesos que exigen consistencia, predictibilidad, maquetación visual y control de animaciones deben resolverse de forma programática utilizando plantillas y código tradicional. La Inteligencia Artificial debe reservarse estrictamente para tareas creativas, de extracción de información y de procesamiento de lenguaje natural.

Aprende a construir un cerebro digital con Python, FastAPI y CrewAI. Descubre cómo orquestar agentes de IA locales con Llama y optimizar tu arquitectura combinando modelos probabilísticos con plantillas programáticas deterministas.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.