weather: any city he names, not the six in a table (V-421)
The hand-written table understood "какая погода в X" for six values of X. Ask about Kazan or Tbilisi and the city was dropped silently and answered for the default location — a correct-sounding answer about the wrong place. The table is gone. internal/weather already calls Open-Meteo's geocoding endpoint on every lookup, so the place he named goes straight there and any place it knows is a place he can ask about. He speaks the prepositional case, so locationCandidates reverses the two endings that cover most of it: a final "е" is a nominative "а" or nothing, a final "и" is a soft sign. A wrong candidate finds no city; it never invents one. A place the geocoder does not have now reads as "не знаю такого города" rather than as a provider outage or, worse, as the default city's weather. ErrLocationUnknown is what carries that apart. "в" followed by a room or a day word is still the default location. Those questions are answered by the house sensors and the calendar, not by Open-Meteo, and they must not be read as a city.
This commit is contained in:
@@ -97,7 +97,59 @@ func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// locationCandidates — the spellings to try for a place taken out of a spoken
|
||||
// sentence, in order. He says "какая погода в Казани", so the word arrives in
|
||||
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
|
||||
//
|
||||
// Two cheap reversals cover most of what he says: a final "е" is usually a
|
||||
// nominative "а" (Москве → Москва) or nothing at all (Лондоне → Лондон), and a
|
||||
// final "и" is usually a soft sign (Казани → Казань). Indeclinable names —
|
||||
// Тбилиси, Сочи, Осло — are already nominative and the first candidate answers.
|
||||
//
|
||||
// Nothing here is a guess about the weather: a wrong candidate finds no city
|
||||
// and the caller says so. It only decides which strings are worth asking about.
|
||||
func locationCandidates(location string) []string {
|
||||
out := []string{location}
|
||||
add := func(s string) {
|
||||
if s == "" || s == location {
|
||||
return
|
||||
}
|
||||
for _, seen := range out {
|
||||
if seen == s {
|
||||
return
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
r := []rune(location)
|
||||
if len(r) < 4 {
|
||||
return out
|
||||
}
|
||||
stem := string(r[:len(r)-1])
|
||||
switch r[len(r)-1] {
|
||||
case 'е', 'Е':
|
||||
add(stem + "а")
|
||||
add(stem)
|
||||
case 'и', 'И':
|
||||
add(stem + "ь")
|
||||
add(stem)
|
||||
case 'у', 'У', 'ю', 'Ю':
|
||||
add(stem + "а")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
|
||||
for _, cand := range locationCandidates(location) {
|
||||
lat, lon, name, err = p.geocodeOne(ctx, cand)
|
||||
if err == nil {
|
||||
return lat, lon, name, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, "", err
|
||||
}
|
||||
|
||||
func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (lat, lon float64, name string, err error) {
|
||||
u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
@@ -121,7 +173,7 @@ func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat,
|
||||
}
|
||||
|
||||
if len(geo.Results) == 0 {
|
||||
return 0, 0, "", fmt.Errorf("location %q not found", location)
|
||||
return 0, 0, "", fmt.Errorf("%w: %q", ErrLocationUnknown, location)
|
||||
}
|
||||
|
||||
r := geo.Results[0]
|
||||
|
||||
@@ -83,3 +83,29 @@ func TestStubProvider(t *testing.T) {
|
||||
t.Fatalf("StubProvider: want ErrNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocationCandidates — he speaks the prepositional case and the geocoder
|
||||
// wants the nominative (Vikunja #421).
|
||||
func TestLocationCandidates(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"Москве": {"Москве", "Москва", "Москв"},
|
||||
"Казани": {"Казани", "Казань", "Казан"},
|
||||
"Лондоне": {"Лондоне", "Лондона", "Лондон"},
|
||||
"Тбилиси": {"Тбилиси", "Тбились", "Тбилис"},
|
||||
"Berlin": {"Berlin"},
|
||||
"Уфе": {"Уфе"}, // too short to strip — asked as spoken
|
||||
}
|
||||
for in, want := range cases {
|
||||
got := locationCandidates(in)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("locationCandidates(%q) = %v, want %v", in, got, want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("locationCandidates(%q) = %v, want %v", in, got, want)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ import (
|
||||
|
||||
var ErrNotConfigured = errors.New("weather: not configured")
|
||||
|
||||
// ErrLocationUnknown — the geocoder has no such place. A named city that does
|
||||
// not resolve must read differently from a provider outage: one is "I do not
|
||||
// know that place", the other is "I could not reach the service", and
|
||||
// answering for the default location instead is the defect this replaces
|
||||
// (Vikunja #421).
|
||||
var ErrLocationUnknown = errors.New("weather: location not found")
|
||||
|
||||
type Weather struct {
|
||||
Location string `json:"location"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
|
||||
Reference in New Issue
Block a user