diff --git a/CLAUDE.md b/CLAUDE.md index 45cec54..752fa1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,8 +231,10 @@ fact or a route is the defect; a regex over structured input — HTML, MIME, JSO argv list — is not. Before writing a Russian word list, pick one of these: - **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives, - capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is - a data change, and there is exactly one copy: months used to live in three files. + capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months, + spoken hours. Editing a word is a data change, and there is exactly one copy: months used + to live in three files. Cardinals carry the oblique forms, because a spoken time declines + and `в семь` / `к семи` are one hour. - **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm` and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb slot that means the imperative must be matched exactly — `говори` and `говорил` are one diff --git a/internal/weather/openmeteo.go b/internal/weather/openmeteo.go index 4eedf2d..29818b9 100644 --- a/internal/weather/openmeteo.go +++ b/internal/weather/openmeteo.go @@ -8,6 +8,9 @@ import ( "net/http" "net/url" "time" + "unicode" + + "github.com/kami/maven/internal/morph" ) type OpenMeteoProvider struct { @@ -101,10 +104,20 @@ func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) // 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. +// The dictionary answers first (Vikunja #530). internal/morph lemmatises +// "Уфе" to "Уфа" and "Москве" to "Москва", which is the same question this +// used to guess at by reversing endings, asked of something that knows. +// +// The reversals stay behind it, because the dictionary does not know every +// place: "Твери" and "Перми" come back unchanged, and a final "и" is usually a +// soft sign. A final "е" is usually a nominative "а" (Москве → Москва) or +// nothing at all (Лондоне → Лондон). Indeclinable names — Тбилиси, Сочи, Осло — +// are already nominative and the first candidate answers, which is why the word +// as spoken is always tried before anything derived from it. +// +// There used to be a four-rune floor here, so "Уфе" was asked as spoken and +// "Уфа" was never tried. The floor was there to stop a two-letter stem, and the +// stem length is what it now tests. // // 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. @@ -121,8 +134,9 @@ func locationCandidates(location string) []string { } out = append(out, s) } + add(titleFirst(morph.Lemma(location))) r := []rune(location) - if len(r) < 4 { + if len(r) < 3 { return out } stem := string(r[:len(r)-1]) @@ -139,6 +153,17 @@ func locationCandidates(location string) []string { return out } +// titleFirst restores the leading capital a place name carries. morph.Lemma +// answers lowercased, because a lemma is a dictionary entry and the dictionary +// has no opinion about proper nouns. +func titleFirst(s string) string { + r := []rune(s) + if len(r) == 0 { + return s + } + return string(unicode.ToUpper(r[0])) + string(r[1:]) +} + 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) diff --git a/internal/weather/openmeteo_test.go b/internal/weather/openmeteo_test.go index b51aae3..8d84fb8 100644 --- a/internal/weather/openmeteo_test.go +++ b/internal/weather/openmeteo_test.go @@ -86,14 +86,20 @@ func TestStubProvider(t *testing.T) { // TestLocationCandidates — he speaks the prepositional case and the geocoder // wants the nominative (Vikunja #421). +// +// The dictionary answers before the reversals now, so the nominative it knows +// comes second and anything derived by hand follows (V-530). "Уфе" used to fall +// under a four-rune floor and was asked as spoken, so "Уфа" was never tried. func TestLocationCandidates(t *testing.T) { cases := map[string][]string{ "Москве": {"Москве", "Москва", "Москв"}, "Казани": {"Казани", "Казань", "Казан"}, - "Лондоне": {"Лондоне", "Лондона", "Лондон"}, + "Лондоне": {"Лондоне", "Лондон", "Лондона"}, "Тбилиси": {"Тбилиси", "Тбились", "Тбилис"}, "Berlin": {"Berlin"}, - "Уфе": {"Уфе"}, // too short to strip — asked as spoken + "Уфе": {"Уфе", "Уфа", "Уф"}, + // The dictionary does not know it, so the soft-sign reversal answers. + "Твери": {"Твери", "Тверь", "Твер"}, } for in, want := range cases { got := locationCandidates(in)