Course EN
← back to chapter

Home automation

Command a simulated five-room house in plain language: "turn off the lights in

Showcase — Home automation

Command a simulated five-room house in plain language: "turn off the lights in every unoccupied room", "cool anything warmer than 24 down to 21". Conditional commands force the read-then-act pattern — the model must call get_state, reason over what came back, and only then issue the set_light and set_temperature calls that qualify. The reply appends a change list computed from the state diff in Python — not the model's own account of what it did — plus the final house state, so what you see is what actually happened.

Run

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

Open http://localhost:3000.

To run against Gemini instead:

PROVIDER=gemini docker compose up --build

What's where

  • backend/ai_openai.py — the house state, three tools (one read, two writes), a per-request dispatch closure, and the bounded loop
  • backend/ai_gemini.py — same house, Gemini spelling
  • backend/main.py — identical FastAPI loader; reads PROVIDER and dispatches
  • frontend/app/page.tsx — textarea + result
  • docker-compose.yml — two services, secrets mounted from ./secrets/

Every request gets a fresh copy of the house (copy.deepcopy), so runs are repeatable and two users can't trample each other's lights. The "Changes applied" section is a Python diff of before-vs-after state — models sometimes misreport their own side effects, so the report never relies on the model's memory of what it called.

Stop

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.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