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:
@@ -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:
|
argv list — is not. Before writing a Russian word list, pick one of these:
|
||||||
|
|
||||||
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||||||
capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is
|
capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months,
|
||||||
a data change, and there is exactly one copy: months used to live in three files.
|
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`
|
- **`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
|
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
|
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OpenMeteoProvider struct {
|
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
|
// sentence, in order. He says "какая погода в Казани", so the word arrives in
|
||||||
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
|
// 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
|
// The dictionary answers first (Vikunja #530). internal/morph lemmatises
|
||||||
// nominative "а" (Москве → Москва) or nothing at all (Лондоне → Лондон), and a
|
// "Уфе" to "Уфа" and "Москве" to "Москва", which is the same question this
|
||||||
// final "и" is usually a soft sign (Казани → Казань). Indeclinable names —
|
// used to guess at by reversing endings, asked of something that knows.
|
||||||
// Тбилиси, Сочи, Осло — are already nominative and the first candidate answers.
|
//
|
||||||
|
// 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
|
// 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.
|
// 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)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
|
add(titleFirst(morph.Lemma(location)))
|
||||||
r := []rune(location)
|
r := []rune(location)
|
||||||
if len(r) < 4 {
|
if len(r) < 3 {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
stem := string(r[:len(r)-1])
|
stem := string(r[:len(r)-1])
|
||||||
@@ -139,6 +153,17 @@ func locationCandidates(location string) []string {
|
|||||||
return out
|
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) {
|
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
|
||||||
for _, cand := range locationCandidates(location) {
|
for _, cand := range locationCandidates(location) {
|
||||||
lat, lon, name, err = p.geocodeOne(ctx, cand)
|
lat, lon, name, err = p.geocodeOne(ctx, cand)
|
||||||
|
|||||||
@@ -86,14 +86,20 @@ func TestStubProvider(t *testing.T) {
|
|||||||
|
|
||||||
// TestLocationCandidates — he speaks the prepositional case and the geocoder
|
// TestLocationCandidates — he speaks the prepositional case and the geocoder
|
||||||
// wants the nominative (Vikunja #421).
|
// 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) {
|
func TestLocationCandidates(t *testing.T) {
|
||||||
cases := map[string][]string{
|
cases := map[string][]string{
|
||||||
"Москве": {"Москве", "Москва", "Москв"},
|
"Москве": {"Москве", "Москва", "Москв"},
|
||||||
"Казани": {"Казани", "Казань", "Казан"},
|
"Казани": {"Казани", "Казань", "Казан"},
|
||||||
"Лондоне": {"Лондоне", "Лондона", "Лондон"},
|
"Лондоне": {"Лондоне", "Лондон", "Лондона"},
|
||||||
"Тбилиси": {"Тбилиси", "Тбились", "Тбилис"},
|
"Тбилиси": {"Тбилиси", "Тбились", "Тбилис"},
|
||||||
"Berlin": {"Berlin"},
|
"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 {
|
for in, want := range cases {
|
||||||
got := locationCandidates(in)
|
got := locationCandidates(in)
|
||||||
|
|||||||
Reference in New Issue
Block a user