PythonCompilersAIEducationEspañol

Construyendo KarelPy: un IDE para Karel en Python con generación de código por IA

March 30, 202615 min read

Karel es una de las herramientas más queridas para enseñar programación. Si creciste en México participando en la OMI (Olimpiada Mexicana de Informática), casi con certeza escribiste tu primer algoritmo para este pequeño robot. La implementación más popular hoy es karel.js de OmegaUp — un IDE completo en el navegador donde los estudiantes pueden escribir, ejecutar y visualizar programas Karel.

Quería construir algo similar pero completamente en Python, con una variante: una sintaxis que se siente como Python en lugar de Pascal, y un asistente de IA que pueda generar código Karel a partir de una descripción en lenguaje natural.

Esta es la historia de cómo construí KarelPy.


¿Qué es Karel?

Karel es un robot que vive en un mundo de cuadrícula. Puede orientarse en cuatro direcciones (Norte, Sur, Este, Oeste), avanzar, girar a la izquierda, recoger y dejar "zumbadores" (fichas), y detectar su entorno. El lenguaje no tiene variables ni aritmética — solo movimiento, condicionales, ciclos y recursión. Esa simplicidad es exactamente lo que lo hace perfecto para enseñar.

Un programa típico de Karel se ve así:

def programa():
    while frente_libre():
        avanza()
    gira_izquierda()
    apagate()

Simple, legible y aun así suficientemente poderoso para expresar algoritmos no triviales.


Visión general de la arquitectura

KarelPy está dividido en dos capas completamente independientes:

karelpy/          ← Paquete Python puro (sin dependencias web)
web/              ← Capa web con FastAPI (consume el paquete)

Esta separación significa que el motor Karel puede ser probado, importado y reutilizado sin necesidad de levantar un servidor web. La capa web es solo un envoltorio delgado.


Parte 1: Construyendo el motor Karel

El pipeline

Cada vez que el usuario hace click en "Ejecutar", el código pasa por un pipeline clásico de compilador:

Código fuente → Lexer → Tokens → Parser → AST → Intérprete → Traza de ejecución

Cada etapa es un módulo separado con una única responsabilidad.

Paso 1: El Lexer

El lexer convierte el texto fuente en una lista plana de tokens. La parte más compleja de un lexer estilo Python es manejar la indentación significativa — el lenguaje usa espacios en blanco para definir bloques en lugar de { } o inicio/fin.

La solución es un stack de indentación. Cada vez que la indentación aumenta, se emite un token INDENT. Cada vez que disminuye, se emiten uno o más tokens DEDENT.

# lexer.py
indent_stack = [0]

for line_num, line in enumerate(lines, start=1):
    indent = 0
    while indent < len(line) and line[indent] == ' ':
        indent += 1

    if indent > indent_stack[-1]:
        tokens.append(Token(TokenType.INDENT, indent, line_num, 1))
        indent_stack.append(indent)
    elif indent < indent_stack[-1]:
        while indent_stack[-1] > indent:
            indent_stack.pop()
            tokens.append(Token(TokenType.DEDENT, indent_stack[-1], line_num, 1))

Esto convierte la indentación en tokens estructurales explícitos, lo que hace al parser mucho más sencillo de escribir.

Paso 2: El Parser

El parser usa descenso recursivo — un método por regla gramatical, cada uno devolviendo un nodo del AST. Lee tokens de la salida del lexer y construye un árbol.

# parser.py
def _parse_while(self) -> WhileLoop:
    token = self.expect(TokenType.WHILE)
    condition = self._parse_condition()
    self.expect(TokenType.COLON)
    body = self._parse_block()          # parsea recursivamente el bloque indentado
    return WhileLoop(condition=condition, body=body, line=token.line)

Cada nodo del árbol es un dataclass de Python — sin lógica, solo datos:

# ast_nodes.py
@dataclass
class WhileLoop(Node):
    condition: Optional[Condition] = None
    body: List[Node] = field(default_factory=list)

@dataclass
class Condition(Node):
    name: str = ""
    negated: bool = False   # maneja la palabra clave `not`

Paso 3: El mundo y el robot

El mundo es una cuadrícula 2D. Las paredes se almacenan como un conjunto de tuplas (col, fila, dirección). Cada pared interior se almacena dos veces — una desde cada lado — por lo que las verificaciones de colisión son búsquedas O(1).

# world.py
def has_wall(self, col: int, row: int, direction: str) -> bool:
    dc, dr = DIRECTION_DELTA[direction]
    ncol, nrow = col + dc, row + dr
    if not self.in_bounds(ncol, nrow):
        return True          # los bordes del mundo son paredes implícitas
    return (col, row, direction) in self.walls

El robot es aún más simple — solo un dataclass con cuatro campos:

# robot.py
@dataclass
class Robot:
    col: int = 1
    row: int = 1
    direction: str = "NORTH"
    bag: int = 0        # -1 significa zumbadores infinitos

Paso 4: El intérprete y la traza de ejecución

Esta es la decisión de diseño más importante del proyecto. Una implementación ingenua ejecutaría el programa y devolvería solo el estado final del mundo — pero eso hace imposible la animación.

En su lugar, el intérprete registra un snapshot después de cada instrucción primitiva. El resultado es una lista de estados del mundo — la traza de ejecución completa.

# interpreter.py
def _exec_primitive(self, name: str, line: int):
    self.step_count += 1
    if self.step_count > MAX_STEPS:
        raise MaxStepsError("Posible ciclo infinito detectado.")

    r, w = self.robot, self.world

    if name == "avanza":
        if w.has_wall(r.col, r.row, r.direction):
            raise KarelRuntimeError("Karel chocó con una pared.", line)
        dc, dr = DIRECTION_DELTA[r.direction]
        r.col += dc
        r.row += dr

    elif name == "gira_izquierda":
        r.direction = TURN_LEFT[r.direction]

    # ... otras instrucciones primitivas ...

    self._snapshot()    # ← registrar estado después de cada instrucción

El frontend recibe todos los snapshots de una sola vez y los anima del lado del cliente — sin WebSockets, sin streaming, sin estado en el servidor.

Recursión gratis

Uno de los resultados más elegantes de este diseño: la recursión simplemente funciona. Cuando el intérprete llama a una función definida por el usuario, llama a _exec_block — una función Python normal. Si esa función se llama a sí misma, Python apila los frames de forma natural.

def _exec_call(self, stmt: Call):
    if stmt.name in self.functions:
        try:
            self._exec_block(self.functions[stmt.name].body)
        except KarelReturn:
            pass    # termina() sale solo de la función actual

La instrucción termina() funciona como un return — lanza una excepción KarelReturn que es capturada en el límite de la llamada a función, no en el nivel superior. apagate() lanza KarelShutdown, que se propaga hasta arriba y termina el programa.

Este es un programa Karel recursivo que camina hasta una pared y luego deshace el camino:

def copy_move():
    if frente_bloqueado():
        gira_derecha()
        termina()       # caso base: salir de esta llamada, desapilar
    avanza()
    copy_move()         # llamada recursiva
    avanza()            # se ejecuta al regresar por el stack

def programa():
    copy_move()
    apagate()

La API pública

Todo el motor se expone a través de una sola función:

# karelpy/__init__.py
def run(code: str, world_dict: dict, robot_dict: dict) -> dict:
    try:
        tokens  = Lexer(code).tokenize()
        program = Parser(tokens).parse()
        world   = World.from_dict(world_dict)
        robot   = Robot.from_dict(robot_dict)
        steps   = Interpreter(world, robot).run(program)
        return {"ok": True, "steps": steps}
    except KarelError as exc:
        return {"ok": False, "error": str(exc), "line": getattr(exc, "line", None)}

Nunca lanza excepciones — los errores se devuelven como datos. Esto hace que la capa web sea trivialmente simple.


Parte 2: La capa web

El backend

FastAPI envuelve el motor en dos endpoints:

# routes/run.py
@router.post("/api/run")
def run_karel(req: RunRequest) -> dict:
    return run(req.code, req.world, req.robot)

Eso es literalmente todo el endpoint de ejecución. El motor hace todo el trabajo.

El frontend

El frontend tiene dos clases principales:

WorldRenderer dibuja la cuadrícula de Karel en un Canvas HTML. Convierte el sistema de coordenadas de Karel (col, fila) (origen abajo a la izquierda) a píxeles del canvas (origen arriba a la izquierda):

toCanvas(col, row) {
    return {
        x: (col - 1) * this.cellSize,
        y: (this.worldHeight - row) * this.cellSize,
    };
}

El robot se dibuja como un triángulo que rota según la dirección de Karel:

_drawRobot({ col, row, dir }) {
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(DIR_ANGLE[dir]);   // { NORTH: 0, EAST: π/2, SOUTH: π, WEST: -π/2 }

    ctx.beginPath();
    ctx.moveTo(0, -r);            // punta del triángulo = frente de Karel
    ctx.lineTo(-r * 0.7, r * 0.55);
    ctx.lineTo( r * 0.7, r * 0.55);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
}

KarelApp gestiona el editor (CodeMirror), se comunica con la API y controla la reproducción. Después de recibir la traza de ejecución, anima los pasos con un timer:

_tick() {
    if (!this.playing) return;
    if (this.currentStep >= this.steps.length - 1) { this.pause(); return; }

    this.currentStep++;
    this._renderStep();
    this._updateCounter();

    const speed = parseInt(document.getElementById('speed-slider').value);
    this.playTimer = setTimeout(() => this._tick(), Math.round(1000 / speed));
}

El usuario también puede hacer click en el canvas para editar el mundo. Hacer click cerca del borde de una celda coloca una pared; hacer click en el centro agrega un zumbador:

_clickZone(e) {
    const { localX, localY } = this._cellAt(e);
    const t = Math.max(6, Math.floor(this.renderer.cellSize * 0.2));
    if (localY < t)            return 'NORTH';
    if (localY > cellSize - t) return 'SOUTH';
    if (localX < t)            return 'WEST';
    if (localX > cellSize - t) return 'EAST';
    return 'center';
}

Parte 3: Generación de código con IA

La última funcionalidad es un asistente de IA que genera código Karel a partir de una descripción en texto. El usuario puede escribir "haz que Karel recoja todos los zumbadores de su fila" y recibir código funcional al instante.

La estrategia: prompt engineering, no fine-tuning

Karel es un lenguaje pequeño — unas 10 instrucciones y 20 condiciones. GPT-4o ya conoce Karel en sus variantes Java y Pascal. Lo único que necesita es contexto sobre esta sintaxis específica.

El enfoque, en orden de menor a mayor complejidad:

  1. System prompt con la referencia de sintaxis completa — inyectar SINTAXIS.md en cada request
  2. Ejemplos few-shot — 5 pares problema/solución hardcodeados en el prompt
  3. RAG — un banco de problemas Karel resueltos (no necesario todavía)
  4. Fine-tuning — no es necesario

Los niveles 1 y 2 son suficientes para la gran mayoría de los problemas Karel.

# routes/generate.py
def _build_system_prompt() -> str:
    sintaxis = SINTAXIS_PATH.read_text(encoding="utf-8")   # siempre fresco del disco

    return f"""Eres un experto en KarelPy.
Genera SOLO código KarelPy válido dentro de un bloque ```python ... ```.
Siempre define programa() como punto de entrada.
Usa únicamente las instrucciones y condiciones definidas en la referencia de sintaxis.

{sintaxis}

{FEW_SHOT}"""

Leer SINTAXIS.md del disco en cada request significa que mejorar la documentación del lenguaje mejora automáticamente la salida de la IA — sin cambios en el código.

El endpoint es directo:

@router.post("/api/generate")
def generate_karel(req: GenerateRequest) -> dict:
    client   = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
    response = client.chat.completions.create(
        model    = "gpt-4o",
        messages = [
            {"role": "system", "content": _build_system_prompt()},
            {"role": "user",   "content": req.prompt},
        ],
    )
    code = _extract_code(response.choices[0].message.content)
    return {"ok": True, "code": code}

La clase AiPanel del frontend envía el request, muestra el código generado y permite insertarlo en el editor con un click:

insertIntoEditor() {
    const code = this.codeOutput.value.trim();
    this.karelApp.editor.setValue(code);
    this.karelApp.editor.focus();
}

Lo que aprendí

Las trazas de ejecución son la abstracción correcta para herramientas educativas. Devolver una lista de snapshots en lugar de hacer streaming de cambios de estado mantiene el backend sin estado, hace que el frontend sea trivialmente pausable y rebobinble, y facilita enormemente el testing.

El call stack de Python es un motor de recursión gratuito. Al implementar el intérprete como un tree-walking interpreter con llamadas a funciones Python normales, las funciones recursivas definidas por el usuario funcionan automáticamente sin ninguna maquinaria adicional.

El prompt engineering supera al fine-tuning para DSLs pequeños. Un system prompt bien escrito con la especificación del lenguaje y algunos ejemplos es suficiente para que un modelo genere código correcto en un lenguaje personalizado que nunca ha visto antes.


Cómo correrlo localmente

git clone https://github.com/fragosoa/karelpy
cd karelpy

pip install -e ".[dev,web]"

# Agrega tu API key de OpenAI (opcional, solo para la generación con IA)
echo "OPENAI_API_KEY=sk-..." > .env

uvicorn web.app:app --reload
# Abre http://localhost:8000

Qué sigue

  • Editor de mundos: un editor visual completo para diseñar mundos Karel con guardado por nombre
  • Biblioteca de problemas: una colección de problemas clásicos de Karel con salidas esperadas para calificación automática
  • Base de conocimiento RAG: un banco de problemas resueltos que la IA pueda usar como contexto de recuperación para retos más difíciles
  • Extensiones de sintaxis: or/and en condiciones, constantes con nombre para cantidades de zumbadores