// Package main — weatherq.go holds the weather-query keyword helpers: does // this utterance ask about weather at all, and which place (if any) did he // name. Both are plain keyword matching, not NLU — extend this file rather // than voice.go for anything in that shape. package main import ( "regexp" "strings" "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/morph" "github.com/kami/maven/internal/router" ) // isWeatherQuery returns true if the utterance is about weather. func isWeatherQuery(u string) bool { lower := strings.ToLower(u) return strings.Contains(lower, "погод") || strings.Contains(lower, "градус") || strings.Contains(lower, "температур") || strings.Contains(lower, "дожд") || strings.Contains(lower, "холод") || strings.Contains(lower, "тепл") || strings.Contains(lower, "weather") || strings.Contains(lower, "temperature") } // weatherPlace — the place he named, after "в"/"во"/"in". One or two words, // letters and dashes only, so "в Нижнем Новгороде" and "in New York" both // come through whole and "в 5 утра" does not. var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`) // weatherRooms — the rooms of the house, which are the only words in this // guard that belong to it. "какая погода в доме" is the smart-home sensor, not // Open-Meteo, and "тепло в комнате" is the same question about the same room. // // The rest of the guard used to be a third copy of three closed sets that // already exist in the lexicon: the weekdays, the parts of the day, and the // words that follow "в" without naming a place (V-581). Each copy was short in // its own direction — "среду" but not "среде", "утром" but not "утра", "целом" // but not "общем" — so the same question phrased one word differently reached // the geocoder as a city. var weatherRooms = map[string]bool{ "доме": true, "квартире": true, "комнате": true, "спальне": true, "гостиной": true, "кухне": true, "гараже": true, "офисе": true, "обед": true, "обеде": true, "выходные": true, "выходных": true, } // isWeatherNonPlace reports whether the word after "в" names something other // than a place he could ask the weather for. func isWeatherNonPlace(word string) bool { if weatherRooms[word] { return true } if _, ok := router.WeekdayIndex(word); ok { return true } for _, w := range lexicon.PartsOfDay() { if word == w || morph.SameWord(word, w) { return true } } for _, w := range lexicon.NotPlaceAfterV() { if word == w { return true } } return false } // extractWeatherLocation returns the place he named, or the configured default // when he named none. It returns "" when he named none AND no default is // configured — the caller must then say it does not know. // // It used to be a hand-written table of six cities in two spellings each // (Vikunja #421). Anything outside it — Kazan, Tbilisi — was dropped silently // and answered for the default location, which reads as a correct answer about // the wrong place. There is a geocoder behind this now: internal/weather // already calls Open-Meteo's geocoding endpoint for every lookup, so any place // it knows is a place he can ask about, and the table bought nothing. // // A named place that the geocoder cannot resolve is the caller's problem to // report, not this function's to hide. // // It used to return "Moscow" when he named nothing. That is a made-up answer // presented as fact. voice.weather.default_location is the only source of an // unstated location. func extractWeatherLocation(u, defaultLoc string) string { m := weatherPlace.FindStringSubmatch(u) if m == nil { return defaultLoc } place := strings.TrimSpace(m[1]) first := strings.ToLower(strings.Fields(place)[0]) if isWeatherNonPlace(first) { return defaultLoc } return place }