From bf95dce666a89a9876838820eb52ea14169ec961 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 19:31:19 +0000 Subject: [PATCH] Expand live weather tool to return full Open-Meteo data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `get_weather` live tool requested only the current temperature/weather code and a max/min/code daily outlook, then rendered a single spoken sentence — leaving the LLM unable to answer follow-ups about humidity, wind, precipitation chance, UV, sunrise/sunset, etc. Request the full relevant Open-Meteo series and render a comprehensive, labelled multi-line report the agent can draw on: - Current: temperature + feels-like (both units), humidity, cloud cover, precipitation, wind speed/direction/gusts, pressure, day/night. - Daily (7 days): high/low (both units), condition, precipitation amount + probability, max wind/gusts/direction, UV index, sunrise/sunset. Surface the geocoded country and add a 16-point compass helper for wind direction. The agent still reads aloud only the slice a turn calls for; the rest is there when the conversation drills in. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GfS9w6EyrSFbVQhthm4MCt --- aai_cli/agent_cascade/weather_tool.py | 207 ++++++++++++++---- .../2026-06-22-live-weather-tool-design.md | 36 ++- tests/test_agent_cascade_weather.py | 180 ++++++++++++--- 3 files changed, 345 insertions(+), 78 deletions(-) diff --git a/aai_cli/agent_cascade/weather_tool.py b/aai_cli/agent_cascade/weather_tool.py index dc3335f1..3419e7b6 100644 --- a/aai_cli/agent_cascade/weather_tool.py +++ b/aai_cli/agent_cascade/weather_tool.py @@ -2,13 +2,20 @@ Backed by Open-Meteo, which needs no API key — so unlike the optional Firecrawl search, this tool is *always* present, giving every live session at least one real -capability. The flow is geocode (place name -> coordinates) -> forecast (current + -a short daily outlook) -> a single short string the agent reads aloud. +capability. The flow is geocode (place name -> coordinates) -> forecast (current +conditions + a multi-day daily outlook) -> a labelled, multi-line report. + +The report is deliberately **comprehensive**, not a single spoken sentence: it +surfaces every field an LLM might need to answer a follow-up — feels-like +temperature, humidity, precipitation (amount *and* probability), wind speed / +direction / gusts, cloud cover, pressure, day/night, the UV index, and sunrise / +sunset — across today plus the next several days. The live agent reads aloud only +the slice the conversation calls for; the rest is there when a turn drills in. The only network seam is :data:`Fetcher` (a ``url -> parsed JSON`` callable), injected in tests so the whole flow runs with no sockets — the same shape -other URL-fetch tools in the live agent use. Everything else (the WMO-code text, the spoken -formatting) is pure and tested directly. Failures never raise out to the graph: +other URL-fetch tools in the live agent use. Everything else (the WMO-code text, the +spoken formatting) is pure and tested directly. Failures never raise out to the graph: ``get_weather`` catches them and returns a short spoken apology so a weather outage can't sink a live turn. """ @@ -35,7 +42,42 @@ _GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" _FORECAST_URL = "https://api.open-meteo.com/v1/forecast" _TIMEOUT = 15.0 # pragma: no mutate — a tuning knob; ±a few seconds is equivalent -_FORECAST_DAYS = 3 # today + the next two days (the two spoken outlook lines) +_FORECAST_DAYS = 7 # today + the next six days of daily outlook + +# The current-conditions variables we ask Open-Meteo for (defaults: °C, km/h, %, mm, hPa). +_CURRENT_SERIES = ( + "temperature_2m", + "apparent_temperature", + "relative_humidity_2m", + "precipitation", + "weather_code", + "cloud_cover", + "wind_speed_10m", + "wind_direction_10m", + "wind_gusts_10m", + "pressure_msl", + "is_day", +) +# The daily-outlook variables. ``time`` is always returned by Open-Meteo and is *not* +# a requestable variable, so it is read from the response but kept out of the request. +_DAILY_VARS = ( + "weather_code", + "temperature_2m_max", + "temperature_2m_min", + "precipitation_sum", + "precipitation_probability_max", + "wind_speed_10m_max", + "wind_gusts_10m_max", + "wind_direction_10m_dominant", + "uv_index_max", + "sunrise", + "sunset", +) +# Every parallel daily array we read out of the response (includes the auto-returned time). +_DAILY_SERIES = ("time", *_DAILY_VARS) +# The arrays a day row must have to be worth rendering; the shortest of them bounds how +# many complete days we emit, so a ragged response skips trailing days instead of raising. +_DAILY_REQUIRED = ("temperature_2m_max", "temperature_2m_min", "weather_code") # WMO weather-interpretation codes -> short spoken phrases. A code not listed here # (Open-Meteo can add more) falls back in :func:`describe_weather_code` rather than @@ -69,8 +111,25 @@ 99: "severe thunderstorms with hail", } -# Spoken labels for the next two forecast days (index 1 and 2 of the daily arrays). -_DAY_LABELS = ("Tomorrow", "Then") +# 16-point compass, indexed by (bearing / 22.5) rounded mod 16, for spoken wind direction. +_COMPASS = ( + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", +) def describe_weather_code(code: int) -> str: @@ -78,11 +137,26 @@ def describe_weather_code(code: int) -> str: return _WMO_DESCRIPTIONS.get(code, "unsettled weather") +def describe_wind_direction(degrees: int) -> str: + """Return the 16-point compass name (e.g. ``NW``) for a wind bearing in degrees.""" + return _COMPASS[round(degrees / 22.5) % 16] + + def _c_to_f(celsius: float) -> int: """Convert Celsius to a rounded Fahrenheit integer for the spoken report.""" return round(celsius * 9 / 5 + 32) +def _num(value: float) -> str: + """Render a measurement to at most one decimal, dropping a trailing ``.0`` (``2`` not ``2.0``).""" + return f"{round(value, 1):g}" + + +def _local_time(iso: str) -> str: + """Pull the ``HH:MM`` out of an Open-Meteo local timestamp (``2026-06-22T05:48`` -> ``05:48``).""" + return iso[11:16] if "T" in iso else iso + + def _get_json(url: str) -> object: """GET ``url`` and return its parsed JSON body (the default network seam).""" import httpx2 as httpx @@ -92,12 +166,13 @@ def _get_json(url: str) -> object: return response.json() -def _geocode(name: str, *, fetch: Fetcher) -> tuple[str, float, float] | None: - """Resolve a place name to ``(display name, latitude, longitude)``, or None. +def _geocode(name: str, *, fetch: Fetcher) -> tuple[str, str | None, float, float] | None: + """Resolve a place name to ``(display name, country, latitude, longitude)``, or None. Asks Open-Meteo's geocoding endpoint for the single best match. No match (an empty or absent ``results`` list) returns None so the tool can speak a clear - "couldn't find that place" instead of guessing. + "couldn't find that place" instead of guessing. ``country`` is None when the + match carries no country (so the report just names the place). """ query = urlencode({"name": name, "count": 1, "language": "en", "format": "json"}) payload = jsonshape.as_mapping(fetch(f"{_GEOCODE_URL}?{query}")) @@ -105,21 +180,23 @@ def _geocode(name: str, *, fetch: Fetcher) -> tuple[str, float, float] | None: if not results: return None top = results[0] + country = top.get("country") return ( str(top.get("name", name)), + str(country) if country is not None else None, jsonshape.as_float(top.get("latitude")), jsonshape.as_float(top.get("longitude")), ) def _forecast(lat: float, lon: float, *, fetch: Fetcher) -> dict[str, object]: - """Fetch the current conditions plus a short daily outlook for coordinates.""" + """Fetch the full current conditions plus a multi-day daily outlook for coordinates.""" query = urlencode( { "latitude": lat, "longitude": lon, - "current": "temperature_2m,weather_code", - "daily": "temperature_2m_max,temperature_2m_min,weather_code", + "current": ",".join(_CURRENT_SERIES), + "daily": ",".join(_DAILY_VARS), "forecast_days": _FORECAST_DAYS, "timezone": "auto", } @@ -127,34 +204,88 @@ def _forecast(lat: float, lon: float, *, fetch: Fetcher) -> dict[str, object]: return jsonshape.as_mapping(fetch(f"{_FORECAST_URL}?{query}")) or {} -def _forecast_lines(daily: dict[str, object]) -> list[str]: - """The spoken outlook lines for the next days, e.g. ``Tomorrow 9 to 17°C, rain.``""" - highs = jsonshape.object_list(daily.get("temperature_2m_max")) - lows = jsonshape.object_list(daily.get("temperature_2m_min")) - codes = jsonshape.object_list(daily.get("weather_code")) - lines: list[str] = [] - for offset, label in enumerate(_DAY_LABELS, start=1): - if offset < len(highs) and offset < len(lows) and offset < len(codes): - low = round(jsonshape.as_float(lows[offset])) - high = round(jsonshape.as_float(highs[offset])) - cond = describe_weather_code(jsonshape.as_int(codes[offset])) - lines.append(f"{label} {low} to {high}°C, {cond}.") - return lines +def _format_current(name: str, country: str | None, current: dict[str, object]) -> list[str]: + """Render the current-conditions block as a few labelled sentences.""" + place = f"{name}, {country}" if country else name + temp = jsonshape.as_float(current.get("temperature_2m")) + feels = jsonshape.as_float(current.get("apparent_temperature")) + desc = describe_weather_code(jsonshape.as_int(current.get("weather_code"))) + daypart = "daytime" if jsonshape.as_int(current.get("is_day")) else "nighttime" + humidity = round(jsonshape.as_float(current.get("relative_humidity_2m"))) + cloud = round(jsonshape.as_float(current.get("cloud_cover"))) + precip = jsonshape.as_float(current.get("precipitation")) + wind = round(jsonshape.as_float(current.get("wind_speed_10m"))) + gust = round(jsonshape.as_float(current.get("wind_gusts_10m"))) + bearing = round(jsonshape.as_float(current.get("wind_direction_10m"))) + pressure = round(jsonshape.as_float(current.get("pressure_msl"))) + return [ + f"Current conditions in {place}: {round(temp)}°C ({_c_to_f(temp)}°F), " + f"feels like {round(feels)}°C ({_c_to_f(feels)}°F), {desc}, {daypart}.", + f"Humidity {humidity}%, cloud cover {cloud}%, precipitation {_num(precip)} mm.", + f"Wind {wind} km/h from the {describe_wind_direction(bearing)} ({bearing}°), " + f"gusting to {gust} km/h.", + f"Pressure {pressure} hPa.", + ] + + +def _day_label(offset: int, date: str) -> str: + """Label a daily row: today/tomorrow carry their date, later days are the date alone.""" + if offset == 0: + return f"Today ({date})" + if offset == 1: + return f"Tomorrow ({date})" + return date + + +def _daily_rows(daily: dict[str, object]) -> list[dict[str, object]]: + """Transpose Open-Meteo's parallel daily arrays into per-day row mappings. + + The number of complete rows is bounded by the shortest *required* array, so a + response that drops a trailing value simply yields fewer days instead of raising. + """ + columns = {field: jsonshape.object_list(daily.get(field)) for field in _DAILY_SERIES} + count = min(len(columns[field]) for field in _DAILY_REQUIRED) + return [ + {field: values[index] for field, values in columns.items() if index < len(values)} + for index in range(count) + ] + + +def _format_day(offset: int, row: dict[str, object]) -> str: + """Render one daily row as a single comprehensive outlook line.""" + date = str(row.get("time", "")) + high = jsonshape.as_float(row.get("temperature_2m_max")) + low = jsonshape.as_float(row.get("temperature_2m_min")) + cond = describe_weather_code(jsonshape.as_int(row.get("weather_code"))) + precip = _num(jsonshape.as_float(row.get("precipitation_sum"))) + prob = round(jsonshape.as_float(row.get("precipitation_probability_max"))) + wind = round(jsonshape.as_float(row.get("wind_speed_10m_max"))) + gust = round(jsonshape.as_float(row.get("wind_gusts_10m_max"))) + bearing = round(jsonshape.as_float(row.get("wind_direction_10m_dominant"))) + uv = _num(jsonshape.as_float(row.get("uv_index_max"))) + sunrise = _local_time(str(row.get("sunrise", ""))) + sunset = _local_time(str(row.get("sunset", ""))) + return ( + f"{_day_label(offset, date)}: high {round(high)}°C ({_c_to_f(high)}°F), " + f"low {round(low)}°C ({_c_to_f(low)}°F), {cond}. " + f"Precipitation {precip} mm, {prob}% chance. " + f"Wind up to {wind} km/h, gusts {gust} km/h from the {describe_wind_direction(bearing)}. " + f"UV index {uv}. Sunrise {sunrise}, sunset {sunset}." + ) -def format_report(name: str, data: dict[str, object]) -> str: - """Render the Open-Meteo forecast as one short, speakable string. +def format_report(name: str, country: str | None, data: dict[str, object]) -> str: + """Render the Open-Meteo forecast as one comprehensive, labelled multi-line report. - The current temperature is given in both units (the agent speaks whichever fits - the conversation); the outlook days stay in °C to keep the spoken reply short. + Temperatures are given in both units (the agent speaks whichever fits the + conversation); everything else uses Open-Meteo's metric defaults (km/h, %, mm, + hPa). The current block is followed by one line per forecast day. """ current = jsonshape.as_mapping(data.get("current")) or {} daily = jsonshape.as_mapping(data.get("daily")) or {} - temp = jsonshape.as_float(current.get("temperature_2m")) - desc = describe_weather_code(jsonshape.as_int(current.get("weather_code"))) - lines = [f"In {name} it's {round(temp)}°C ({_c_to_f(temp)}°F) and {desc}."] - lines.extend(_forecast_lines(daily)) - return " ".join(lines) + lines = _format_current(name, country, current) + lines.extend(_format_day(offset, row) for offset, row in enumerate(_daily_rows(daily))) + return "\n".join(lines) def build_weather_tool(fetch: Fetcher = _get_json) -> BaseTool: @@ -163,14 +294,14 @@ def build_weather_tool(fetch: Fetcher = _get_json) -> BaseTool: @tool(WEATHER_TOOL_NAME) def get_weather(location: str) -> str: - """Get the current weather and a short forecast for a place by name (e.g. a + """Get the current weather and a multi-day forecast for a place by name (e.g. a city). Use when asked about the weather, temperature, or forecast somewhere.""" try: located = _geocode(location, fetch=fetch) if located is None: return f"I couldn't find a place called '{location}'." - name, lat, lon = located - return format_report(name, _forecast(lat, lon, fetch=fetch)) + name, country, lat, lon = located + return format_report(name, country, _forecast(lat, lon, fetch=fetch)) except Exception: # Best-effort: a transient Open-Meteo outage (the fetch seam raises) must # not bubble into brain's "couldn't complete the turn" path and kill the diff --git a/docs/superpowers/specs/2026-06-22-live-weather-tool-design.md b/docs/superpowers/specs/2026-06-22-live-weather-tool-design.md index 2ef2a4d8..fe39ed86 100644 --- a/docs/superpowers/specs/2026-06-22-live-weather-tool-design.md +++ b/docs/superpowers/specs/2026-06-22-live-weather-tool-design.md @@ -31,7 +31,12 @@ needs no sockets. the live voice agent. The coding agent's toolset is unchanged. - **Data source: Open-Meteo (keyless).** Free, no signup, with a companion geocoding endpoint to turn a place name into coordinates. -- **Coverage: current conditions + short forecast** (today + next two days). +- **Coverage: full current conditions + a multi-day forecast** (today + the next + six days). The report surfaces every field an LLM might draw on to answer a + follow-up — temperature and feels-like, humidity, precipitation (amount *and* + probability), wind speed / direction / gusts, cloud cover, pressure, day/night, + the UV index, and sunrise / sunset — rather than a single spoken sentence. The + agent reads aloud only the slice the conversation calls for. ### Out of scope (YAGNI) @@ -59,20 +64,27 @@ get_weather(location) ──▶ _geocode(location) ──▶ Open-Meteo ge - `Fetcher = Callable[[str], object]` — GETs a URL and returns parsed JSON. The default `_get_json` uses `httpx`; tests inject a fake mapping URLs → canned JSON. **This is the only network seam.** -- `_geocode(name, *, fetch)` → resolved display name + latitude/longitude, or - `None` when there is no match. Endpoint: +- `_geocode(name, *, fetch)` → resolved display name + country + latitude/longitude, + or `None` when there is no match. Endpoint: `https://geocoding-api.open-meteo.com/v1/search?name=&count=1`. -- `_forecast(lat, lon, *, fetch)` → the `current` and `daily` blocks. Endpoint: - `https://api.open-meteo.com/v1/forecast` with - `current=temperature_2m,weather_code`, - `daily=temperature_2m_max,temperature_2m_min,weather_code`, - `forecast_days=3`, temperatures in Celsius (°F derived in formatting). +- `_forecast(lat, lon, *, fetch)` → the full `current` and `daily` blocks. Endpoint: + `https://api.open-meteo.com/v1/forecast` with the full current series + (`temperature_2m`, `apparent_temperature`, `relative_humidity_2m`, + `precipitation`, `weather_code`, `cloud_cover`, `wind_speed_10m`, + `wind_direction_10m`, `wind_gusts_10m`, `pressure_msl`, `is_day`) and daily series + (`weather_code`, `temperature_2m_max/min`, `precipitation_sum`, + `precipitation_probability_max`, `wind_speed_10m_max`, `wind_gusts_10m_max`, + `wind_direction_10m_dominant`, `uv_index_max`, `sunrise`, `sunset`), + `forecast_days=7`, `timezone=auto`. Temperatures in Celsius (°F derived in formatting). - `describe_weather_code(code)` — pure WMO weather-code → human text ("partly cloudy", "light rain", …) with a fallback for an unknown code. -- `format_report(name, current, daily)` — pure → a short speakable string, e.g. - *"In Paris it's 14°C (57°F) and partly cloudy. Tomorrow 9 to 17°C, light rain. - Then 11 to 19°C, clear."* Temperatures are given in both units; °F is computed - as `round(c * 9 / 5 + 32)`. +- `describe_wind_direction(degrees)` — pure bearing → 16-point compass name. +- `format_report(name, country, data)` — pure → a comprehensive, labelled + multi-line report: a current-conditions block (both units for temperature/ + feels-like, plus humidity, cloud cover, precipitation, wind, pressure, day/night) + followed by one line per forecast day (high/low in both units, condition, + precipitation amount + probability, wind + gusts + direction, UV index, sunrise/ + sunset). °F is computed as `round(c * 9 / 5 + 32)`. - `build_weather_tool(fetch=_get_json)` — the `@tool(WEATHER_TOOL_NAME)` wrapper exposing `get_weather(location: str) -> str`. The `fetch` seam is injectable for hermetic tests. Plus `WEATHER_TOOL_NAME`, these are the module's only diff --git a/tests/test_agent_cascade_weather.py b/tests/test_agent_cascade_weather.py index 4ff88156..841b63f4 100644 --- a/tests/test_agent_cascade_weather.py +++ b/tests/test_agent_cascade_weather.py @@ -6,6 +6,8 @@ from __future__ import annotations +from urllib.parse import parse_qs, urlsplit + from aai_cli.agent_cascade import weather_tool # Canned Open-Meteo payloads keyed by URL prefix, replayed through the fetch seam. @@ -13,15 +15,55 @@ "results": [{"name": "Paris", "latitude": 48.85, "longitude": 2.35, "country": "France"}] } _FORECAST: dict[str, object] = { - "current": {"temperature_2m": 14.3, "weather_code": 2}, + "current": { + "temperature_2m": 14.3, + "apparent_temperature": 12.8, + "relative_humidity_2m": 72, + "precipitation": 0.0, + "weather_code": 2, + "cloud_cover": 60, + "wind_speed_10m": 12.0, + "wind_direction_10m": 315, + "wind_gusts_10m": 25.0, + "pressure_msl": 1013.0, + "is_day": 1, + }, "daily": { "time": ["2026-06-22", "2026-06-23", "2026-06-24"], + "weather_code": [2, 61, 0], "temperature_2m_max": [17.2, 17.0, 19.1], "temperature_2m_min": [9.0, 9.4, 11.2], - "weather_code": [2, 61, 0], + "precipitation_sum": [0.0, 4.2, 0.0], + "precipitation_probability_max": [0, 60, 10], + "wind_speed_10m_max": [20.0, 24.0, 18.0], + "wind_gusts_10m_max": [35.0, 40.0, 30.0], + "wind_direction_10m_dominant": [315, 225, 0], + "uv_index_max": [5.0, 4.5, 6.0], + "sunrise": ["2026-06-22T05:48", "2026-06-23T05:48", "2026-06-24T05:49"], + "sunset": ["2026-06-22T21:56", "2026-06-23T21:57", "2026-06-24T21:57"], }, } +# The exact report the canned forecast must render to — pins every field/round/unit so a +# mutated format string, dropped field, or wrong conversion fails this assertion. +_EXPECTED_LINES = ( + "Current conditions in Paris, France: 14°C (58°F), feels like 13°C (55°F), " + "partly cloudy, daytime.", + "Humidity 72%, cloud cover 60%, precipitation 0 mm.", + "Wind 12 km/h from the NW (315°), gusting to 25 km/h.", + "Pressure 1013 hPa.", + "Today (2026-06-22): high 17°C (63°F), low 9°C (48°F), partly cloudy. " + "Precipitation 0 mm, 0% chance. Wind up to 20 km/h, gusts 35 km/h from the NW. " + "UV index 5. Sunrise 05:48, sunset 21:56.", + "Tomorrow (2026-06-23): high 17°C (63°F), low 9°C (49°F), light rain. " + "Precipitation 4.2 mm, 60% chance. Wind up to 24 km/h, gusts 40 km/h from the SW. " + "UV index 4.5. Sunrise 05:48, sunset 21:57.", + "2026-06-24: high 19°C (66°F), low 11°C (52°F), clear sky. " + "Precipitation 0 mm, 10% chance. Wind up to 18 km/h, gusts 30 km/h from the N. " + "UV index 6. Sunrise 05:49, sunset 21:57.", +) +_EXPECTED_REPORT = "\n".join(_EXPECTED_LINES) + def _fake_fetch(geocode=_GEOCODE, forecast=_FORECAST): """A fetch seam that returns canned geocode/forecast JSON by URL.""" @@ -45,6 +87,38 @@ def test_describe_weather_code_unknown_falls_back(): assert weather_tool.describe_weather_code(999) == "unsettled weather" +# --- describe_wind_direction ------------------------------------------------- + + +def test_describe_wind_direction_cardinals_and_wraparound(): + # Pins the /22.5 step, the %16 wrap, and a few table indices: a mutated divisor, + # modulus, or compass entry shifts at least one of these. + assert weather_tool.describe_wind_direction(0) == "N" + assert weather_tool.describe_wind_direction(45) == "NE" + assert weather_tool.describe_wind_direction(90) == "E" + assert weather_tool.describe_wind_direction(180) == "S" + assert weather_tool.describe_wind_direction(225) == "SW" + assert weather_tool.describe_wind_direction(315) == "NW" + assert weather_tool.describe_wind_direction(360) == "N" # wraps via %16 + + +# --- _num / _local_time ------------------------------------------------------ + + +def test_num_drops_trailing_zero_and_keeps_one_decimal(): + assert weather_tool._num(0.0) == "0" + assert weather_tool._num(5.0) == "5" + assert weather_tool._num(4.2) == "4.2" + assert weather_tool._num(2.34) == "2.3" # rounds to one decimal + + +def test_local_time_extracts_hh_mm_and_passes_through_plain(): + assert weather_tool._local_time("2026-06-22T05:48") == "05:48" + # Truncated to HH:MM even when seconds trail (pins the slice end, not just its start). + assert weather_tool._local_time("2026-06-22T05:48:30") == "05:48" + assert weather_tool._local_time("") == "" # no "T": returned unchanged + + # --- _geocode ---------------------------------------------------------------- @@ -56,12 +130,19 @@ def fetch(url: str) -> object: return _GEOCODE result = weather_tool._geocode("Paris", fetch=fetch) - assert result == ("Paris", 48.85, 2.35) + assert result == ("Paris", "France", 48.85, 2.35) assert "geocoding-api.open-meteo.com" in seen["url"] assert "name=Paris" in seen["url"] assert "count=1" in seen["url"] +def test_geocode_without_country_yields_none_country(): + def fetch(url: str) -> object: + return {"results": [{"name": "Atlantis", "latitude": 0.0, "longitude": 0.0}]} + + assert weather_tool._geocode("Atlantis", fetch=fetch) == ("Atlantis", None, 0.0, 0.0) + + def test_geocode_no_results_is_none(): assert weather_tool._geocode("Nowhereville", fetch=lambda url: {"results": []}) is None @@ -73,7 +154,7 @@ def test_geocode_missing_results_key_is_none(): # --- _forecast --------------------------------------------------------------- -def test_forecast_requests_current_and_daily_for_coordinates(): +def test_forecast_requests_full_current_and_daily_series_for_coordinates(): seen = {} def fetch(url: str) -> object: @@ -82,24 +163,45 @@ def fetch(url: str) -> object: data = weather_tool._forecast(48.85, 2.35, fetch=fetch) assert data == _FORECAST - assert "api.open-meteo.com/v1/forecast" in seen["url"] - assert "latitude=48.85" in seen["url"] - assert "longitude=2.35" in seen["url"] - assert "current=temperature_2m" in seen["url"] - assert "daily=temperature_2m_max" in seen["url"] - assert "forecast_days=3" in seen["url"] + + parts = urlsplit(seen["url"]) + assert parts.path.endswith("/v1/forecast") + qs = {key: value[0] for key, value in parse_qs(parts.query).items()} + assert qs["latitude"] == "48.85" + assert qs["longitude"] == "2.35" + assert qs["forecast_days"] == "7" + assert qs["timezone"] == "auto" + # The full field sets are pinned literally (not against the module constants) so a + # mutated/dropped/reordered variable fails here rather than sailing through. + assert qs["current"] == ( + "temperature_2m,apparent_temperature,relative_humidity_2m,precipitation," + "weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m," + "pressure_msl,is_day" + ) + assert qs["daily"] == ( + "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum," + "precipitation_probability_max,wind_speed_10m_max,wind_gusts_10m_max," + "wind_direction_10m_dominant,uv_index_max,sunrise,sunset" + ) # --- format_report ----------------------------------------------------------- -def test_format_report_renders_current_in_both_units_and_two_forecast_days(): - report = weather_tool.format_report("Paris", _FORECAST) - # Current line: rounded °C, derived °F, and the condition text. - assert "In Paris it's 14°C (58°F) and partly cloudy." in report - # Two forecast days, labelled, °C lows-to-highs with their own conditions. - assert "Tomorrow 9 to 17°C, light rain." in report - assert "Then 11 to 19°C, clear sky." in report +def test_format_report_renders_full_current_and_every_forecast_day(): + assert weather_tool.format_report("Paris", "France", _FORECAST) == _EXPECTED_REPORT + + +def test_format_report_without_country_and_at_night(): + # country=None drops the ", " suffix; an absent is_day reads as nighttime. + data: dict[str, object] = { + "current": {"temperature_2m": 10.0, "weather_code": 0}, + "daily": _FORECAST["daily"], + } + report = weather_tool.format_report("Testville", None, data) + assert report.startswith("Current conditions in Testville: 10°C (50°F),") + assert "nighttime." in report + assert ", None" not in report # the country suffix is omitted, not rendered as None # --- build_weather_tool (end to end via the seam) ---------------------------- @@ -108,9 +210,7 @@ def test_format_report_renders_current_in_both_units_and_two_forecast_days(): def test_tool_name_and_happy_path(): tool = weather_tool.build_weather_tool(fetch=_fake_fetch()) assert tool.name == weather_tool.WEATHER_TOOL_NAME == "get_weather" - out = tool.invoke({"location": "Paris"}) - assert "In Paris it's 14°C (58°F) and partly cloudy." in out - assert "Tomorrow 9 to 17°C, light rain." in out + assert tool.invoke({"location": "Paris"}) == _EXPECTED_REPORT def test_tool_location_not_found_message(): @@ -128,24 +228,25 @@ def boom(url: str) -> object: assert tool.invoke({"location": "Paris"}) == "I couldn't get the weather right now." -# --- _forecast_lines length guard ------------------------------------------- +# --- _daily_rows ragged-array guard ----------------------------------------- -def test_format_report_skips_a_day_when_a_daily_array_is_short(): - # weather_code shorter than the temp arrays: the length guard must skip the - # missing days rather than IndexError. Kills the `and`->`or` guard mutation. +def test_format_report_skips_trailing_days_when_a_required_array_is_short(): + # weather_code shorter than the temp arrays: the shortest *required* array bounds the + # row count, so only today renders. Kills the min()->max() and the count mutations. data: dict[str, object] = { - "current": {"temperature_2m": 10.0, "weather_code": 0}, + "current": {"temperature_2m": 10.0, "weather_code": 0, "is_day": 1}, "daily": { + "time": ["2026-06-22", "2026-06-23", "2026-06-24"], "temperature_2m_max": [12.0, 13.0, 14.0], "temperature_2m_min": [5.0, 6.0, 7.0], "weather_code": [0], # only today's code present }, } - report = weather_tool.format_report("Testville", data) - assert "In Testville it's 10°C" in report + report = weather_tool.format_report("Testville", None, data) + assert "Today (2026-06-22):" in report assert "Tomorrow" not in report - assert "Then" not in report + assert "2026-06-24:" not in report # --- _WMO_DESCRIPTIONS table pin -------------------------------------------- @@ -185,6 +286,29 @@ def test_wmo_descriptions_table_is_exact(): } +def test_compass_table_is_exact(): + # The 16-point table backs describe_wind_direction; pin it so a reordered/renamed + # point is caught even where the bearing tests don't sample that index. + assert weather_tool._COMPASS == ( + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", + ) + + def test_get_json_fetches_and_parses_via_httpx(monkeypatch): # Exercises the default network seam (httpx GET -> raise_for_status -> json), mocking # httpx2 so no socket opens. Asserts the URL/timeout passthrough and that the response is