Project: A Weather Assistant
What This Project Builds
This project pulls together every piece of this unit into one coherent, runnable assistant: a conversational weather assistant that can look up current conditions for a city, retrieve a short forecast, and convert between temperature units — using multiple tools (Lesson 4), a complete request/execute/respond loop (Lesson 3), carefully designed schemas (Lesson 2), and defensive handling of errors and untrusted input (Lesson 5). Unlike the smaller, single-concept examples used throughout this unit, this project is built as a small, organized module rather than a single function, reflecting how a real tool-calling feature is typically structured in an actual codebase.
Step 1: The Underlying Data Functions
Real weather data would come from an external weather API; this project uses a small, deterministic fake data source so the example is runnable without external dependencies or an API key, while keeping every structural decision identical to what a real implementation would need.
FAKE_WEATHER_DATA = {
"boston": {"condition": "cloudy", "temp_celsius": 14, "forecast": ["rain", "rain", "cloudy"]},
"miami": {"condition": "sunny", "temp_celsius": 29, "forecast": ["sunny", "sunny", "sunny"]},
"chicago": {"condition": "windy", "temp_celsius": 9, "forecast": ["windy", "cloudy", "sunny"]},
}
def _normalize_city(city: str) -> str:
return city.strip().lower()
def fetch_current_conditions(city: str) -> dict:
key = _normalize_city(city)
if key not in FAKE_WEATHER_DATA:
return {"success": False, "error": f"No weather data available for '{city}'."}
data = FAKE_WEATHER_DATA[key]
return {"success": True, "city": city, "condition": data["condition"], "temp_celsius": data["temp_celsius"]}
def fetch_forecast(city: str, days: int) -> dict:
key = _normalize_city(city)
if key not in FAKE_WEATHER_DATA:
return {"success": False, "error": f"No forecast data available for '{city}'."}
forecast = FAKE_WEATHER_DATA[key]["forecast"][:days]
return {"success": True, "city": city, "forecast": forecast}
def convert_temperature(value: float, from_unit: str, to_unit: str) -> dict:
if from_unit == to_unit:
return {"success": True, "value": value, "unit": to_unit}
if from_unit == "celsius" and to_unit == "fahrenheit":
return {"success": True, "value": round(value * 9 / 5 + 32, 1), "unit": "fahrenheit"}
if from_unit == "fahrenheit" and to_unit == "celsius":
return {"success": True, "value": round((value - 32) * 5 / 9, 1), "unit": "celsius"}
return {"success": False, "error": f"Unsupported unit conversion: {from_unit} to {to_unit}"}
Each function follows the pattern established across this unit: every function returns a structured dictionary with a success key rather than raising an exception or returning a bare value, following Lesson 5's guidance that a function's result should always be something the calling loop can pass straight back to the model regardless of whether the underlying operation succeeded. _normalize_city() exists specifically because the model's arguments are untrusted input in the sense Lesson 5 described — a user might ask about "Boston", "boston", or "BOSTON, MA", and normalizing before lookup avoids a spurious "not found" result caused only by a superficial formatting difference rather than an actual data gap.
Step 2: Defining the Tool Schemas
weather_tools = [
{
"type": "function",
"name": "get_current_conditions",
"description": "Get the current weather conditions and temperature for a specific city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "The city name, e.g. 'Boston'."},
},
"required": ["city"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "get_forecast",
"description": "Get a multi-day weather forecast for a specific city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "The city name, e.g. 'Boston'."},
"days": {"type": "integer", "minimum": 1, "maximum": 3, "description": "Number of days to forecast, from 1 to 3."},
},
"required": ["city", "days"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "convert_temperature",
"description": "Convert a temperature value between Celsius and Fahrenheit.",
"parameters": {
"type": "object",
"properties": {
"value": {"type": "number"},
"from_unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
"to_unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["value", "from_unit", "to_unit"],
"additionalProperties": False,
},
},
]
Each of the three schemas applies a specific technique from Lesson 2: days is bounded with minimum/maximum since the underlying fake forecast data (and any real forecast API) only supports a limited range, preventing the model from requesting an unreasonable number of days; from_unit and to_unit are constrained with enum rather than left as free-text strings, since temperature units form exactly the kind of small, fixed set Lesson 2 argued enums are meant for; and every property includes a description giving the model enough context to fill in each argument correctly from a natural-language request.
Step 3: The Function Registry and Dispatcher
import json
weather_functions = {
"get_current_conditions": lambda args: fetch_current_conditions(args["city"]),
"get_forecast": lambda args: fetch_forecast(args["city"], args["days"]),
"convert_temperature": lambda args: convert_temperature(args["value"], args["from_unit"], args["to_unit"]),
}
def dispatch_weather_call(call) -> dict:
if call.name not in weather_functions:
return {"success": False, "error": f"Unknown function: {call.name}"}
try:
args = json.loads(call.arguments)
return weather_functions[call.name](args)
except Exception as e:
return {"success": False, "error": f"Error executing {call.name}: {e}"}
This follows Lesson 4's registry pattern, mapping each tool's name to a small wrapper that unpacks the parsed arguments into the right positional call — using a lambda here rather than a bare function reference specifically because each underlying function's parameter order and names differ, and the wrapper is what normalizes "however dispatch_weather_call is invoked" into "however each specific function actually expects to be called." The try/except here follows Lesson 5's guidance directly: any failure, whether from JSON parsing or from the function itself, is caught and returned as a structured error rather than allowed to propagate and crash the calling loop.
Step 4: The Conversation Loop
def run_weather_assistant_turn(client, user_message: str, conversation_history: list | None = None, max_rounds: int = 5) -> tuple[str, list]:
input_messages = list(conversation_history) if conversation_history else []
input_messages.append({"role": "user", "content": user_message})
for _ in range(max_rounds):
response = client.responses.create(
model="gpt-5.6-terra",
instructions=(
"You are a helpful weather assistant. Use the available tools to answer "
"questions about current conditions, forecasts, and temperature conversions. "
"If a city isn't recognized, say so clearly rather than guessing weather data."
),
input=input_messages,
tools=weather_tools,
)
function_calls = [item for item in response.output if item.type == "function_call"]
if not function_calls:
input_messages.append({"role": "assistant", "content": response.output_text})
return response.output_text, input_messages
for call in function_calls:
result = dispatch_weather_call(call)
input_messages.append(call)
input_messages.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result),
})
return "I wasn't able to complete that request after several attempts.", input_messages
This function follows Lesson 3's full-loop pattern, extended in one practical way: it accepts and returns conversation_history, letting a calling application maintain an ongoing multi-turn conversation across separate calls to this function — mirroring Unit 4's conversation-state guidance, since a weather assistant used interactively ("what about tomorrow?" as a follow-up to an earlier question about a specific city) needs the same full-history-every-time treatment Unit 4 established for ordinary conversation, now extended to include the function-call and function-result items this unit has added to that history. The instructions explicitly tell the model to say so clearly when a city isn't recognized rather than guessing — a direct application of Unit 6 and Unit 7's repeated theme that a model should be steered toward honest "I don't know" or "this isn't available" responses rather than confident fabrication, applied here to weather data specifically.
Step 5: A Simple Interactive Entry Point
def main():
import openai
client = openai.OpenAI()
print("Weather Assistant (type 'quit' to exit)")
history = []
while True:
user_input = input("\nYou: ").strip()
if user_input.lower() == "quit":
break
reply, history = run_weather_assistant_turn(client, user_input, history)
print(f"Assistant: {reply}")
if __name__ == "__main__":
main()
This entry point keeps history alive across the while loop's iterations, passing it into run_weather_assistant_turn() on every turn and capturing the updated history it returns — this is what lets a user ask "what's the weather in Boston?" followed by "what about in Fahrenheit?" and have the second question correctly understood as referring to the same city from the first question, since the full prior exchange (including the earlier function call and its result) is present in history for the model to draw on.
Step 6: Testing the Assistant's Logic
Following this course's dependency-injection testing pattern, the dispatch and data functions can be tested directly, without any real model call.
def test_fetch_current_conditions_known_city():
result = fetch_current_conditions("Boston")
assert result["success"] is True
assert result["condition"] == "cloudy"
print("PASS: fetch_current_conditions returns expected data for a known city")
def test_fetch_current_conditions_unknown_city():
result = fetch_current_conditions("Atlantis")
assert result["success"] is False
assert "No weather data" in result["error"]
print("PASS: fetch_current_conditions handles an unrecognized city gracefully")
def test_convert_temperature_celsius_to_fahrenheit():
result = convert_temperature(0, "celsius", "fahrenheit")
assert result["value"] == 32.0
print("PASS: convert_temperature correctly converts 0°C to 32°F")
def test_dispatch_weather_call_handles_case_insensitive_city():
class FakeCall:
name = "get_current_conditions"
arguments = '{"city": "BOSTON"}'
result = dispatch_weather_call(FakeCall())
assert result["success"] is True
print("PASS: dispatch_weather_call normalizes city name case before lookup")
test_fetch_current_conditions_known_city()
test_fetch_current_conditions_unknown_city()
test_convert_temperature_celsius_to_fahrenheit()
test_dispatch_weather_call_handles_case_insensitive_city()
These four tests cover the assistant's actual logic — data lookup for both known and unknown cities, a unit conversion calculation, and case-insensitive dispatch — entirely independently of the model, the conversation loop, or any real API call, catching regressions in the underlying functions immediately and cheaply. A smaller number of real end-to-end tests against the full run_weather_assistant_turn() function, run less frequently, would still be worth having to confirm the model reliably selects the right tool for representative questions, but the bulk of day-to-day verification belongs in fast, free tests like these.
Guarding Against a Common Multi-Tool Failure Mode
With three tools registered, one realistic failure mode worth testing for directly is the model choosing get_current_conditions when the user actually asked about a future day, or vice versa — since "what's the weather in Boston" and "what will the weather be like in Boston tomorrow" are similar sentences that should route to two different tools. A quick way to check this without guessing is to log which tool was actually selected for a batch of representative test questions and review the results by eye.
def log_tool_selection_for_test_questions(client, test_questions: list[str]) -> list[dict]:
results = []
for question in test_questions:
response = client.responses.create(
model="gpt-5.6-terra",
instructions="You are a helpful weather assistant. Use the available tools as needed.",
input=[{"role": "user", "content": question}],
tools=weather_tools,
)
calls = [item.name for item in response.output if item.type == "function_call"]
results.append({"question": question, "tools_called": calls})
return results
test_questions = [
"What's the weather like in Boston right now?",
"Will it rain in Chicago over the next two days?",
"What is 75 degrees Fahrenheit in Celsius?",
"Should I bring an umbrella to Miami tomorrow?",
]
for entry in log_tool_selection_for_test_questions(client, test_questions):
print(f"{entry['question']!r} -> {entry['tools_called']}")
Running this against a handful of representative questions and reading the printed tool-selection log by eye is a fast, practical way to catch a systematic selection problem (every "will it rain tomorrow"-style question incorrectly triggering get_current_conditions instead of get_forecast, say) before it reaches real users, and it directly follows Lesson 4's guidance that ambiguous tool selection is usually best diagnosed and fixed by sharpening each tool's description rather than by adding special-case logic to the calling code. If this logging reveals a consistent misselection, the fix belongs in the description fields of the affected tools in Step 2 — for instance, making get_current_conditions's description explicit that it is for right now, and get_forecast's explicit that it covers future days — rather than in the conversation loop itself.
Troubleshooting Checklist
When this assistant produces unexpected behavior in practice, working through this checklist tends to isolate the cause quickly:
- Is the wrong tool being selected for a given question? Use the tool-selection logging technique above against a representative set of test questions, and sharpen the relevant tool descriptions (Lesson 2) if a pattern of misselection turns up.
- Is a city name failing to match due to formatting? Confirm
_normalize_city()is actually being applied consistently across every lookup path, since an inconsistently normalized city name is a common source of spurious "not found" results. - Is the conversation history being carried forward correctly across turns? A follow-up question like "what about tomorrow?" only resolves correctly if
historyfrom the previous call torun_weather_assistant_turn()was actually passed into the next call — a missed or resethistoryargument is a common integration bug in a calling application built around this project. - Is
max_roundsbeing hit unexpectedly? If the assistant returns the "wasn't able to complete that request" fallback message, check whether the model is stuck requesting the same tool repeatedly with slightly different arguments, which often indicates the tool's error messages (Lesson 5) aren't giving the model enough information to correct its next attempt. - Are dispatch errors being surfaced usefully? Confirming that
dispatch_weather_call()'sexceptbranch produces a message specific enough for the model to act on (rather than a generic, uninformative string) is worth a deliberate check, following Lesson 5's guidance on curating error messages rather than passing raw exception text straight through.
Extending the Project
A few natural directions to extend this project, each exercising a technique this unit or earlier units already covered: replacing FAKE_WEATHER_DATA with a real weather API call (applying Lesson 5's timeout and error-handling guidance to that real network call); adding a get_severe_weather_alerts tool for a specific city, following Lesson 2's schema-design guidance for its parameters; combining the assistant's text replies with the text-to-speech capability from Unit 7, Lesson 4 to produce a fully spoken weather assistant; and adding a location-history tool that remembers a user's most recently asked-about city, letting a follow-up question like "what about tomorrow?" resolve correctly even without the city being restated, building on this project's existing conversation_history mechanism.