feat(weather): romanise non-Latin place names before geocoding

Open-Meteo's geocoder only matches Latin/English spellings, so a Korean city
name like "서울" returns zero results even though the place exists. With
OUTPUT_LANGUAGE locked to Korean the tool-calling model naturally fills
`location` with the Korean name, which dead-ended on every weather request
("could not find location").

When the first geocode is empty and the name is non-ASCII, ask the warm small
model for the common English exonym ("서울" -> "Seoul") and retry once. ASCII
names skip the round-trip entirely.

Live-verified: "서울 날씨 알려줘" now returns real Seoul weather. Tests cover the
romanise-and-retry path and the ASCII short-circuit.
This commit is contained in:
javis-bot
2026-06-12 21:59:42 +09:00
parent f3a1d92620
commit ccbaed9030
2 changed files with 148 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ from src.jarvis.tools.builtin.weather import (
WeatherTool,
WMO_CODES,
_extract_place_from_user_text,
_romanise_place,
)
from src.jarvis.tools.base import ToolContext
from src.jarvis.tools.types import ToolExecutionResult
@@ -100,6 +101,77 @@ class TestWeatherTool:
assert "7-day forecast" in result.reply_text
self.context.user_print.assert_called()
@patch('src.jarvis.tools.builtin.weather._romanise_place')
@patch('requests.get')
def test_run_non_latin_location_romanises_and_retries(self, mock_get, mock_romanise):
"""A Korean city name Open-Meteo can't geocode is romanised and retried.
Open-Meteo's geocoder only matches Latin spellings, so ``서울`` returns
no results. The tool must romanise to ``Seoul`` and retry rather than
dead-ending with "could not find location" (the failure the Korean-
locked deployment hit on every weather request)."""
mock_romanise.return_value = "Seoul"
geo_empty = Mock()
geo_empty.raise_for_status = Mock()
geo_empty.json.return_value = {} # no "results"
geo_seoul = Mock()
geo_seoul.raise_for_status = Mock()
geo_seoul.json.return_value = {
"results": [{
"latitude": 37.566,
"longitude": 126.9784,
"name": "Seoul",
"country": "South Korea",
"admin1": "Seoul",
}]
}
weather_response = Mock()
weather_response.raise_for_status = Mock()
weather_response.json.return_value = {
"current": {
"time": "2026-06-12T14:00",
"temperature_2m": 24.0,
"apparent_temperature": 25.0,
"relative_humidity_2m": 55,
"weather_code": 0,
"wind_speed_10m": 6.0,
"wind_gusts_10m": 10.0,
},
"hourly": {
"time": [f"2026-06-12T{h:02d}:00" for h in range(24)],
"temperature_2m": [20 + h * 0.2 for h in range(24)],
"weather_code": [0] * 24,
},
"daily": {
"time": [f"2026-06-{12+d:02d}" for d in range(7)],
"weather_code": [0, 1, 2, 3, 1, 0, 2],
"temperature_2m_max": [25, 26, 24, 23, 27, 28, 25],
"temperature_2m_min": [16, 17, 15, 14, 18, 19, 16],
},
}
mock_get.side_effect = [geo_empty, geo_seoul, weather_response]
result = self.tool.run({"location": "서울"}, self.context)
assert result.success is True
assert "Seoul" in result.reply_text
mock_romanise.assert_called_once()
# The romanised name must be what the second geocode actually queried.
assert mock_get.call_args_list[1].kwargs["params"]["name"] == "Seoul"
def test_romanise_place_skips_ascii_names(self):
"""ASCII names already geocode, so no LLM round-trip is spent on them."""
cfg = Mock()
cfg.ollama_base_url = "http://x"
cfg.ollama_chat_model = "qwen2.5:3b"
cfg.tool_router_model = ""
cfg.intent_judge_model = ""
assert _romanise_place("Seoul", cfg) is None
@patch('requests.get')
def test_run_location_not_found(self, mock_get):
"""Test weather with unknown location."""