Project: A Weather Assistant

Ma Mahalakshmi V Updated 16 Sep 2026
11 min read ·Lesson 36 of 224

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:

  1. 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.
  2. 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.
  3. Is the conversation history being carried forward correctly across turns? A follow-up question like "what about tomorrow?" only resolves correctly if history from the previous call to run_weather_assistant_turn() was actually passed into the next call — a missed or reset history argument is a common integration bug in a calling application built around this project.
  4. Is max_rounds being 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.
  5. Are dispatch errors being surfaced usefully? Confirming that dispatch_weather_call()'s except branch 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.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Project: A Weather Assistant and get answers drawn from it.

Signed-in readers only.