Course EN
← back to chapter

Automatización del hogar

Comanda una casa simulada de cinco cuartos en lenguaje llano: "turn off the

Showcase — Automatización del hogar

Comanda una casa simulada de cinco cuartos en lenguaje llano: "turn off the lights in every unoccupied room", "cool anything warmer than 24 down to 21". Los comandos condicionales fuerzan el patrón leer-luego-actuar — el modelo debe llamar a get_state, razonar sobre lo que regresó, y solo entonces emitir las llamadas a set_light y set_temperature que califiquen. La respuesta agrega una lista de cambios calculada del diff de estado en Python — no el propio recuento del modelo de lo que hizo — más el estado final de la casa, así que lo que ves es lo que de verdad pasó.

Córrelo

bash bootstrap-secrets.sh              # reads ../../../../.env, writes secrets/
docker compose up --build              # default: PROVIDER=openai

Abre http://localhost:3000.

Para correrlo contra Gemini:

PROVIDER=gemini docker compose up --build

Qué hay aquí

  • backend/ai_openai.py — el estado de la casa, tres tools (una lee, dos escriben), un closure de dispatch por petición y el loop acotado
  • backend/ai_gemini.py — la misma casa, en la forma de Gemini
  • backend/main.py — loader de FastAPI idéntico; lee PROVIDER y despacha
  • frontend/app/page.tsx — textarea + resultado
  • docker-compose.yml — dos servicios, secrets montados desde ./secrets/

Cada petición recibe una copia fresca de la casa (copy.deepcopy), así que las corridas son repetibles y dos usuarios no pueden pisarse las luces. La sección "Changes applied" es un diff en Python del estado antes-vs-después — los modelos a veces reportan mal sus propios efectos secundarios, así que el reporte nunca depende de la memoria del modelo de lo que llamó.

Detenlo

docker compose down

Ejecútalo en tu máquina

Descarga el proyecto como ZIP y córrelo con Docker. Levanta un backend FastAPI y un frontend Next.js en localhost:3000.

Descargar home-automation.zip

unzip home-automation.zip
cd home-automation
bash bootstrap-secrets.sh   # one-time: pulls API keys into ./secrets
docker compose up --build   # default provider: openai
# or:  PROVIDER=gemini docker compose up --build

Escribe algo, elige un proveedor y ejecuta el mismo código de Código contra la API real. Requiere iniciar sesión.


  

Los mismos módulos que ejecuta el botón Run. El proyecto completo (frontend, Dockerfile, compose) está en el ZIP, pestaña README.

backend/ai_openai.py

"""Week 6 - Showcase 2 (OpenAI): a simulated smart home, read-then-act.

Conditional commands force real chaining: "turn off lights in rooms
warmer than 22" can't be done blind. The model reads state, decides which
rooms qualify, then acts — and the tool results after each write confirm
what actually changed. Each request gets a fresh copy of the house, so
runs are repeatable.
"""
import copy
import json

from openai import OpenAI

_client = OpenAI()

_MODEL = "gpt-5.4-nano"

# The house resets per request — a session-scoped world, not shared state.
_INITIAL = {
    "living room": {"light": "on", "temperature_c": 23.5, "occupied": True},
    "kitchen": {"light": "on", "temperature_c": 21.0, "occupied": False},
    "bedroom": {"light": "off", "temperature_c": 24.0, "occupied": False},
    "office": {"light": "on", "temperature_c": 26.0, "occupied": True},
    "bathroom": {"light": "on", "temperature_c": 22.0, "occupied": False},
}

_TOOLS = [
    {
        "type": "function",
        "name": "get_state",
        "description": "Current state of every room: light, temperature_c, occupied.",
        "parameters": {"type": "object", "properties": {}},
    },
    {
        "type": "function",
        "name": "set_light",
        "description": "Switch one room's light on or off.",
        "parameters": {
            "type": "object",
            "properties": {
                "room": {"type": "string",
                         "enum": ["living room", "kitchen", "bedroom", "office", "bathroom"]},
                "state": {"type": "string", "enum": ["on", "off"]},
            },
            "required": ["room", "state"],
        },
    },
    {
        "type": "function",
        "name": "set_temperature",
        "description": "Set one room's thermostat target in Celsius (10-30).",
        "parameters": {
            "type": "object",
            "properties": {
                "room": {"type": "string",
                         "enum": ["living room", "kitchen", "bedroom", "office", "bathroom"]},
                "celsius": {"type": "number"},
            },
            "required": ["room", "celsius"],
        },
    },
]


def _make_dispatch(house: dict) -> dict:
    def get_state() -> dict:
        return house

    def set_light(room: str, state: str) -> dict:
        house[room]["light"] = state
        return {"room": room, "light": state}

    def set_temperature(room: str, celsius: float) -> dict:
        if not 10 <= celsius <= 30:
            return {"error": "celsius must be between 10 and 30"}
        house[room]["temperature_c"] = celsius
        return {"room": room, "temperature_c": celsius}

    return {"get_state": get_state, "set_light": set_light,
            "set_temperature": set_temperature}


def run(command: str) -> str:
    house = copy.deepcopy(_INITIAL)
    dispatch = _make_dispatch(house)

    input_list = [{"role": "user", "content": command.strip()}]
    response = None
    for _ in range(8):  # bounded — never ship an open loop
        response = _client.responses.create(
            model=_MODEL,
            instructions="You control a smart home. For conditional commands, "
                         "read the state first, then act only on rooms that "
                         "match. Your final summary must list every set_light "
                         "and set_temperature call you made in this conversation, "
                         "with the room and value — repeat them from the tool "
                         "results, never from memory.",
            input=input_list,
            tools=_TOOLS,
        )
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            break
        input_list += response.output
        for call in calls:
            args = json.loads(call.arguments)
            fn = dispatch.get(call.name)
            result = fn(**args) if fn else {"error": f"unknown tool {call.name}"}
            input_list.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result),
            })

    # Report side effects from the state diff, not from the model's memory —
    # the model narrates, but the source of truth is computed.
    changes = []
    for room, before in _INITIAL.items():
        for key, old in before.items():
            new = house[room][key]
            if new != old:
                changes.append(f"- {room}: {key} {old} -> {new}")
    changed = "\n".join(changes) if changes else "(none)"

    summary = response.output_text if response else ""
    return (f"{summary}\n\nChanges applied (computed from state):\n{changed}"
            f"\n\nFinal state:\n{json.dumps(house, indent=2)}")

backend/ai_gemini.py

"""Week 6 - Showcase 2 (Gemini): a simulated smart home, read-then-act."""
import copy
import json
import os

from google import genai
from google.genai import types

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

_MODEL = "gemini-3.1-flash-lite"

_INITIAL = {
    "living room": {"light": "on", "temperature_c": 23.5, "occupied": True},
    "kitchen": {"light": "on", "temperature_c": 21.0, "occupied": False},
    "bedroom": {"light": "off", "temperature_c": 24.0, "occupied": False},
    "office": {"light": "on", "temperature_c": 26.0, "occupied": True},
    "bathroom": {"light": "on", "temperature_c": 22.0, "occupied": False},
}

_ROOMS = ["living room", "kitchen", "bedroom", "office", "bathroom"]

_CONFIG = types.GenerateContentConfig(
    system_instruction="You control a smart home. For conditional commands, "
                       "read the state first, then act only on rooms that "
                       "match. Your final summary must list every set_light "
                       "and set_temperature call you made in this conversation, "
                       "with the room and value — repeat them from the tool "
                       "results, never from memory.",
    tools=[types.Tool(function_declarations=[
        types.FunctionDeclaration(
            name="get_state",
            description="Current state of every room: light, temperature_c, occupied.",
            parameters=types.Schema(type=types.Type.OBJECT, properties={}),
        ),
        types.FunctionDeclaration(
            name="set_light",
            description="Switch one room's light on or off.",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={
                    "room": types.Schema(type=types.Type.STRING, enum=_ROOMS),
                    "state": types.Schema(type=types.Type.STRING, enum=["on", "off"]),
                },
                required=["room", "state"],
            ),
        ),
        types.FunctionDeclaration(
            name="set_temperature",
            description="Set one room's thermostat target in Celsius (10-30).",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={
                    "room": types.Schema(type=types.Type.STRING, enum=_ROOMS),
                    "celsius": types.Schema(type=types.Type.NUMBER),
                },
                required=["room", "celsius"],
            ),
        ),
    ])],
)


def _make_dispatch(house: dict) -> dict:
    def get_state() -> dict:
        return house

    def set_light(room: str, state: str) -> dict:
        house[room]["light"] = state
        return {"room": room, "light": state}

    def set_temperature(room: str, celsius: float) -> dict:
        if not 10 <= celsius <= 30:
            return {"error": "celsius must be between 10 and 30"}
        house[room]["temperature_c"] = celsius
        return {"room": room, "temperature_c": celsius}

    return {"get_state": get_state, "set_light": set_light,
            "set_temperature": set_temperature}


def run(command: str) -> str:
    house = copy.deepcopy(_INITIAL)
    dispatch = _make_dispatch(house)

    contents = [types.Content(role="user", parts=[types.Part(text=command.strip())])]
    response = None
    for _ in range(8):  # bounded — never ship an open loop
        response = _client.models.generate_content(
            model=_MODEL, contents=contents, config=_CONFIG,
        )
        if not response.function_calls:
            break
        contents.append(response.candidates[0].content)
        parts = []
        for fc in response.function_calls:
            fn = dispatch.get(fc.name)
            result = fn(**fc.args) if fn else {"error": f"unknown tool {fc.name}"}
            parts.append(types.Part.from_function_response(name=fc.name, response=result))
        contents.append(types.Content(role="user", parts=parts))

    # Report side effects from the state diff, not from the model's memory —
    # the model narrates, but the source of truth is computed.
    changes = []
    for room, before in _INITIAL.items():
        for key, old in before.items():
            new = house[room][key]
            if new != old:
                changes.append(f"- {room}: {key} {old} -> {new}")
    changed = "\n".join(changes) if changes else "(none)"

    summary = (response.text or "") if response else ""
    return (f"{summary}\n\nChanges applied (computed from state):\n{changed}"
            f"\n\nFinal state:\n{json.dumps(house, indent=2)}")

Archivos del proyecto

  • .gitignore
  • README.es.md
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json