This is an old revision of the document!
For this lab, use the starter archive provided on OCW:
The original repository contains the ROS2/Pupper files. The archive `llm_lab.zip` adds a simplified sandbox for testing the command pipeline before running it on the real robot.
Download `llm_lab.zip` and extract it in the root folder of the repository:
cd ~/lab_9_fall_2025 unzip llm_lab.zip ls llm_lab
After extraction, the repository should contain:
lab_9_fall_2025/
└── llm_lab/
├── commands_config.json
├── llm_prompt.txt
├── command_parser.py
├── karel_pupper.py
├── karel_commander.py
├── mock_llm.py
├── run_karel_test.py
├── run_mock_pipeline.py
├── README.md
└── outputs/
The folder `llm_lab` is used first for local testing. After the local pipeline works, students must connect the command parser to a real LLM and then use the validated commands to control the real Pupper robot.
This lab uses a ROS2 simulator before commands are tested on the real Pupper robot.
The recommended environment is:
Ubuntu 24.04 / WSL2 ROS2 Jazzy Python 3 virtual environment Ollama or another approved local/instructor-provided LLM
The archive for this lab contains both the LLM sandbox and a minimal ROS2 simulator package:
lab_9_fall_2025/
├── llm_lab/
│ ├── command_parser.py
│ ├── karel_commander.py
│ ├── karel_pupper.py
│ ├── ros2_pupper_bridge.py
│ ├── real_llm.py
│ ├── run_sim_llm_pipeline.py
│ ├── run_voice_sim_pipeline.py
│ └── ...
└── src/
└── llm_pupper_sim/
├── package.xml
├── setup.py
├── launch/
│ └── pupper_sim.launch.py
└── llm_pupper_sim/
├── pupper_sim_node.py
└── pupper_marker_node.py
The ROS2 simulator package subscribes to:
/cmd_vel /pupper/high_level_command
and publishes:
/pupper/sim_pose /pupper/marker
The LLM must never publish directly to these ROS2 topics.
The safe structure is:
LLM output
-> sanitize_commands()
-> KarelPupper API
-> ros2_pupper_bridge.py
-> ROS2 topics
-> simulator
If ROS2 is not installed, install ROS2 Jazzy first.
Check the Ubuntu version:
lsb_release -a
For Ubuntu 24.04, use ROS2 Jazzy.
Enable the required repositories:
sudo apt update sudo apt install software-properties-common curl -y sudo add-apt-repository universe -y sudo apt update
Add the ROS2 apt source:
export ROS_APT_SOURCE_VERSION=$(curl -s https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest | grep -F "tag_name" | awk -F'"' '{print $4}') curl -L -o /tmp/ros2-apt-source.deb "https://github.com/ros-infrastructure/ros-apt-source/releases/download/${ROS_APT_SOURCE_VERSION}/ros2-apt-source_${ROS_APT_SOURCE_VERSION}.$(. /etc/os-release && echo $VERSION_CODENAME)_all.deb" sudo dpkg -i /tmp/ros2-apt-source.deb sudo apt update
Install ROS2 Jazzy and development tools:
sudo apt install ros-jazzy-desktop python3-argcomplete ros-dev-tools -y
Source ROS2:
source /opt/ros/jazzy/setup.bash
Optional: add ROS2 to `.bashrc`:
echo "source /opt/ros/jazzy/setup.bash" >> ~/.bashrc source ~/.bashrc
Check that ROS2 works:
ros2 --help
Go to the root folder of the lab:
cd ~/lab_9_fall_2025
or, if the lab is stored inside the Windows filesystem from WSL:
cd /mnt/c/Users/<your_user>/Documents/sumer_school/llm_lab/lab_9_fall_2025
Build the simulator package:
source /opt/ros/jazzy/setup.bash colcon build --packages-select llm_pupper_sim source install/setup.bash
A successful build should create:
build/ install/ log/
and the local setup file:
install/setup.bash
Ubuntu 24 may block global `pip install` commands because the system Python environment is externally managed.
Use a virtual environment instead.
From the `llm_lab` folder:
cd ~/lab_9_fall_2025/llm_lab python3 -m venv --system-site-packages .venv source .venv/bin/activate python -m pip install --upgrade pip python -m pip install requests sounddevice scipy faster-whisper
The option `–system-site-packages` is important because it allows the virtual environment to see ROS2 Python packages such as `rclpy`.
Check that both ROS2 and the lab dependencies are visible:
python -c "import rclpy; print('rclpy OK')" python -c "import requests; print('requests OK')"
The lab can use a local LLM through Ollama.
If Ollama runs inside the same Ubuntu / WSL environment, the default URL is:
http://localhost:11434/api/generate
If Ollama runs on Windows and the ROS2 pipeline runs in WSL, expose Ollama from Windows and call it from WSL.
In Windows PowerShell, start Ollama with:
$env:OLLAMA_HOST="0.0.0.0:11434" ollama serve
If Windows Firewall blocks access from WSL, add an inbound rule for port 11434.
In WSL, find the Windows host IP:
WIN_HOST=$(ip route | awk '/default/ {print $3}') echo $WIN_HOST
Test access to Ollama:
curl http://$WIN_HOST:11434/api/tags
Then set the LLM URL:
export OLLAMA_URL="http://$WIN_HOST:11434/api/generate" export OLLAMA_MODEL="llama3.2:3b"
If Ollama runs directly in WSL, use:
export OLLAMA_URL="http://localhost:11434/api/generate" export OLLAMA_MODEL="llama3.2:3b"
In this lab, you will build a natural language command pipeline for Pupper.
The final pipeline is:
human command
-> real LLM parser
-> safety filter
-> KarelPupper API
-> real Pupper action
The LLM must not execute Python code directly. It is only allowed to return commands from a fixed list.
Allowed commands:
MOVE_FORWARD MOVE_BACKWARD TURN_LEFT TURN_RIGHT SIT STAND WAVE STOP
The safety filter checks the LLM output before any robot command is executed.
Go to the lab folder:
cd ~/lab_9_fall_2025/llm_lab
Run:
python3 run_karel_test.py
Expected output:
[MOCK PUPPER] stand [MOCK PUPPER] move forward [MOCK PUPPER] turn left [MOCK PUPPER] wave [MOCK PUPPER] sit [MOCK PUPPER] stop
This confirms that the high-level Pupper API works in mock mode.
Open:
nano commands_config.json
This file contains the commands that the robot is allowed to execute.
The safety rule is:
Open:
nano command_parser.py
Find the function:
sanitize_commands(raw_commands)
This function validates the output generated by the LLM.
The LLM output is never trusted directly. It must always pass through this safety layer before reaching the robot.
Run:
python3 run_mock_pipeline.py
Try commands such as:
Please move forward and then turn left. Can you say hello? Go back and stop. Run into the wall.
The mock pipeline uses a simple local parser. This is only the baseline. It is used to understand the command flow before connecting a real LLM.
Your first task is to replace the mock parser with a real LLM call.
The LLM must receive the user command and return only commands from the allowed list.
The LLM may be:
You are not required to use a paid personal API key.
The important requirement is that the LLM must transform natural language into robot commands.
Open the prompt file:
nano llm_prompt.txt
The prompt must force the model to follow these rules:
Allowed commands:
MOVE_FORWARD MOVE_BACKWARD TURN_LEFT TURN_RIGHT SIT STAND WAVE STOP
You must implement or adapt a real LLM call so that a command such as:
Please move forward and then turn left.
returns:
MOVE_FORWARD TURN_LEFT
You can create a new file for the real LLM parser, for example:
nano real_llm.py
The function should have this structure:
def real_llm_response(user_text): """ Sends user_text to a real LLM and returns the raw model output. The model output must contain only commands, one per line. """ # TODO: call a real LLM here return raw_llm_output
If you use a local LLM, the function may call a local server such as Ollama.
Example structure:
def real_llm_response(user_text): prompt = read_prompt_from_file("llm_prompt.txt") ``` # TODO: send prompt + user_text to a local LLM # Example target: a local LLM server running on your computer return raw_llm_output ```
If you use an instructor-provided API, the function may call that API instead.
After receiving the LLM output, pass it through the safety parser before executing it.
The required pipeline is:
user text
-> real LLM
-> raw command output
-> sanitize_commands()
-> KarelPupper API
Do not execute code generated by the LLM.
The LLM is allowed to output only text commands. The program decides whether those commands are valid.
A paid API key is not required for this lab.
A free option is to run a local LLM on your computer and call it from Python.
One possible setup is:
student command
-> local LLM
-> command text
-> sanitize_commands()
-> KarelPupper API
Example local LLM flow:
1. Start a local LLM server. 2. Load a small instruction-following model. 3. Send the contents of llm_prompt.txt plus the user command. 4. Receive the model output. 5. Validate the output with sanitize_commands().
Example Python structure:
import requests from pathlib import Path ROOT = Path(**file**).resolve().parent PROMPT_PATH = ROOT / "llm_prompt.txt" def real_llm_response(user_text): prompt = PROMPT_PATH.read_text(encoding="utf-8") ``` response = requests.post( "http://localhost:11434/api/generate", json={ "model": "llama3.2:3b", "prompt": prompt + "\\n\\nUser: " + user_text + "\\nCommands:", "stream": False, "options": { "temperature": 0 } }, timeout=60 ) response.raise_for_status() return response.json()["response"].strip() ```
This is only an example. The exact local model may be changed by the instructor.
Even when using a local LLM, the output must still pass through:
sanitize_commands()
The local LLM is not allowed to execute code or send commands directly to the robot.
Test at least 10 natural language commands.
Your tests must include:
Use the mock parser first, then the real LLM parser.
Example table:
| User command | Mock parser output | Real LLM output | Expected output | Correct? |
|---|---|---|---|---|
| Please go forward | MOVE_FORWARD | … | MOVE_FORWARD | … |
| Turn left and sit | TURN_LEFT, SIT | … | TURN_LEFT, SIT | … |
| Can you say hello? | WAVE | … | WAVE | … |
| Run into the wall | STOP | … | STOP | … |
Write a short conclusion explaining where the real LLM performs better than the simple mock parser.
Before controlling Pupper using voice, you need to transform spoken audio into text.
This step is called speech-to-text or audio transcription.
The idea is simple:
spoken command
-> audio recording
-> speech-to-text model
-> transcribed text
For example:
Audio: "Please move forward and then turn left."
should become:
Please move forward and then turn left.
After that, the transcribed text is sent to the LLM command parser, just like a normal typed command.
The full voice pipeline becomes:
microphone
-> audio file
-> speech-to-text
-> transcribed text
-> LLM command parser
-> sanitize_commands()
-> KarelPupper API
Important: the speech-to-text result is only text. It must not be executed directly. It must still go through the LLM parser and the safety filter.
There are several ways to implement speech-to-text.
You are not required to use a paid transcription API.
The recommended order is:
1. Try a local speech-to-text option. 2. If available, use an instructor-provided API. 3. Use another approved transcription tool if needed.
A local speech-to-text model runs on your own computer.
Advantages:
Disadvantages:
The pipeline is:
microphone
-> audio recording
-> local speech-to-text model
-> transcribed text
-> LLM command parser
Example structure:
def speech_to_text(audio_path): """ Receives the path to an audio file. Returns the transcribed text. """ # TODO: run a local speech-to-text model here return transcribed_text
If the instructor provides an API key or a shared transcription service, you may use it.
Advantages:
Disadvantages:
Example structure:
def speech_to_text(audio_path): with open(audio_path, "rb") as audio_file: # send audio_file to the instructor-provided transcription API # receive transcription pass ``` return transcription_text ```
Realtime speech-to-text processes microphone input continuously.
This is more advanced and should not be the first implementation.
For this lab, start with short audio recordings of 3-5 seconds. After that works, realtime transcription can be added later.
For this lab, use the simple recording-based approach:
1. Record 3-5 seconds of audio. 2. Save the audio as a .wav file. 3. Transcribe the .wav file using a local model or instructor-provided service. 4. Receive the transcription. 5. Send the transcription to the LLM command parser. 6. Validate the LLM output using sanitize_commands(). 7. Execute only validated commands.
The voice command pipeline should look like this:
audio_path = record_audio() text = speech_to_text(audio_path) raw_llm_output = real_llm_response(text) commands = sanitize_commands(raw_llm_output.splitlines()) execute_commands(robot, commands)
Using the speech-to-text approach explained above, extend the pipeline so that the user can speak commands instead of typing them.
Start with short recorded commands of 3-5 seconds. Do not begin with realtime transcription.
The required pipeline is:
microphone
-> audio recording
-> speech-to-text
-> transcribed text
-> real LLM parser
-> sanitize_commands()
-> KarelPupper API
Test the voice pipeline in mock mode first.
Example spoken commands:
Stand up. Move forward. Turn left. Turn right. Sit down. Say hello. Stop. Run into the wall.
For each test, record:
Example table:
| Spoken command | Transcription | LLM output | Validated commands | Mock output |
|---|---|---|---|---|
| Move forward | Move forward | MOVE_FORWARD | MOVE_FORWARD | [MOCK PUPPER] move forward |
| Turn left and sit | Turn left and sit | TURN_LEFT, SIT | TURN_LEFT, SIT | [MOCK PUPPER] turn left / sit |
| Run into the wall | Run into the wall | STOP | STOP | [MOCK PUPPER] stop |
Check that your system can access the microphone.
Try recording a short audio file first, without using the LLM.
Speak clearly and use short commands.
Bad input:
Can you maybe, like, go there and do the thing?
Better input:
Move forward and turn left.
If the transcription is wrong, the LLM may produce the wrong command.
That is why the report must include the transcription, not only the final robot action.
This is normal. The LLM parser should still extract the intended command.
Example:
Please, can you move forward a little bit?
Expected command:
MOVE_FORWARD
If the user says something unsafe, the final validated command should be:
STOP
Example:
Run into the wall.
Expected output:
STOP
Before running commands on the real Pupper robot, test the validated command pipeline in simulation.
The simulator step is important because it lets you check whether the command generated by the LLM is correctly mapped to a robot action before using the physical robot.
The simulated pipeline is:
voice or text command
-> speech-to-text, if voice is used
-> real LLM parser
-> sanitize_commands()
-> KarelPupper API
-> ROS2 simulator / RViz / Gazebo
The LLM must still not publish directly to ROS2 topics. The output of the LLM must always pass through:
sanitize_commands()
Only validated commands may be sent to the robot API.
Open a terminal and source the ROS2 workspace used by the Pupper repository.
Example:
cd ~/lab_9_fall_2025 source install/setup.bash
or, if the workspace is in another folder:
source ~/ros2_ws/install/setup.bash
Then start the simulator using the launch file provided by the repository.
Example structure:
ros2 launch <pupper_simulation_package> <simulation_launch_file>.py
Use the exact simulation launch command provided by the instructor or by the repository.
Depending on the repository, the simulator may use RViz, Gazebo, or both.
The starter version of `KarelPupper` runs in mock mode and only prints actions.
For the simulator, you must connect the high-level KarelPupper functions to the simulated robot interface.
Open:
nano llm_lab/karel_pupper.py
Find the high-level methods:
def move_forward(self): ... def turn_left(self): ... def turn_right(self): ... def sit(self): ... def stand(self): ... def wave(self): ... def stop(self): ...
Modify the implementation so that these functions send commands to the simulated Pupper robot using the existing ROS2/Pupper interface.
Do not send raw LLM output directly to ROS2.
The correct structure is:
LLM output
-> sanitize_commands()
-> MOVE_FORWARD / TURN_LEFT / SIT / ...
-> KarelPupper method
-> ROS2 simulator command
First test the simulator with text input.
Use at least the following commands:
Stand up. Move forward. Turn left. Turn right. Sit down. Stop.
For each command, record:
Example table:
| Input command | LLM output | Validated commands | Simulator behavior |
|---|---|---|---|
| Stand up | STAND | STAND | Simulated robot stands |
| Move forward | MOVE_FORWARD | MOVE_FORWARD | Simulated robot moves forward |
| Turn left | TURN_LEFT | TURN_LEFT | Simulated robot turns left |
| Stop | STOP | STOP | Simulated robot stops |
After the text pipeline, voice pipeline and simulator pipeline work, connect the validated command pipeline to the real Pupper robot.
This step must be done under instructor supervision.
Before running anything on the real robot:
The real robot pipeline must be:
human text or voice command
-> speech-to-text, if voice is used
-> real LLM
-> sanitize_commands()
-> KarelPupper command
-> real Pupper action
The LLM is not allowed to control motors directly. It may only select one or more commands from the allowed command list.
Modify the real robot backend in:
nano llm_lab/karel_pupper.py
The same high-level methods should be used:
robot.stand() robot.move_forward() robot.turn_left() robot.turn_right() robot.sit() robot.wave() robot.stop()
The implementation behind these methods may use ROS2, an existing Pupper API, or another interface provided by the repository.
Demonstrate at least 5 commands on the real Pupper robot using voice input.
Your demonstration must include:
Example spoken commands:
Stand up. Move forward. Turn left and sit. Say hello. Stop. Run into the wall.
For each demonstrated command, record:
Example table:
| Spoken command | Transcription | LLM output | Validated commands | Real robot behavior |
|---|---|---|---|---|
| Stand up | Stand up | STAND | STAND | Robot stands |
| Move forward | Move forward | MOVE_FORWARD | MOVE_FORWARD | Robot moves forward |
| Turn left and sit | Turn left and sit | TURN_LEFT, SIT | TURN_LEFT, SIT | Robot turns left, then sits |
| Run into the wall | Run into the wall | STOP | STOP | Robot stops / does not execute unsafe movement |
Submit:
A language model should not be allowed to control a robot directly.
The safe design is:
voice or text input
-> transcription, if voice is used
-> LLM output
-> validation
-> allowed command
-> high-level robot API
The LLM can interpret natural language, but the program must decide what is safe to execute.