weather asks the dictionary before guessing case (V-530)

locationCandidates reversed endings by hand to turn "в Казани" into the
nominative the geocoder wants. internal/morph knows the answer for the places
it has, so it goes first and the reversals stay behind it for the ones it does
not: "Твери" and "Перми" come back unchanged.

The four-rune floor was there to stop a two-letter stem, so it now tests the
stem instead. "Уфе" was under the floor and "Уфа" was never tried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 21:18:52 +04:00
parent 58b546a27e
commit c62c7034fa
3 changed files with 42 additions and 9 deletions
+4 -2
View File
@@ -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
+30 -5
View File
@@ -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)
+8 -2
View File
@@ -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)