maven: weather module skeleton with open-meteo provider (task 5)

- New internal/weather/ package: Provider interface, Weather struct, StubProvider
- OpenMeteoProvider with geocoding + current weather (keyless, free API)
- Config: WeatherConfig in VoiceConfig (provider, default_location)
- Wire in voice.go as weatherProvider on reactiveHandler
- Handle weather queries in IntentQuery (before notes RAG)
- Helper: isWeatherQuery / extractWeatherLocation
- Tests: mocked HTTP round-trip for OpenMeteo, stub ErrNotConfigured, config tests
- No real network calls in any test

Co-Authored-By: opencode <opencode@anthropic.com>
This commit is contained in:
kami
2026-07-06 04:15:54 +04:00
parent 428af3f3c6
commit e030466cac
6 changed files with 353 additions and 11 deletions
+86 -11
View File
@@ -65,6 +65,7 @@ import (
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/weather"
"github.com/kami/maven/internal/worker"
)
@@ -171,6 +172,18 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*v
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
matcher := tool.NewMatcher(coreAPI)
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
var weatherProvider weather.Provider
var weatherLocation string
if cfg.Voice.Weather != nil && cfg.Voice.Weather.Provider == "open-meteo" {
weatherProvider = weather.NewOpenMeteoProvider()
weatherLocation = cfg.Voice.Weather.DefaultLocation
log.Printf("voice: weather provider: open-meteo (default location: %s)", cfg.Voice.Weather.DefaultLocation)
} else {
weatherProvider = weather.NewStubProvider()
log.Printf("voice: weather provider: stub (not configured)")
}
// ----- router (the cascade; floor examples seed the classifier) -----
// The act matcher's allowlist is exactly the enabled tool names — the
// router only matches acts the executor can run (one source of truth).
@@ -189,15 +202,17 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*v
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI) -----
h := &reactiveHandler{
stt: transcriber,
tts: synthesizer,
router: rtr,
embedder: emb,
api: coreAPI,
tools: exec,
phraser: phr,
replier: voice.NewStubReplier(),
now: time.Now,
stt: transcriber,
tts: synthesizer,
router: rtr,
embedder: emb,
api: coreAPI,
tools: exec,
phraser: phr,
replier: voice.NewStubReplier(),
now: time.Now,
weatherProvider: weatherProvider,
weatherLocation: weatherLocation,
}
// ----- the server (TCP listener) -----
@@ -226,6 +241,9 @@ type reactiveHandler struct {
replier voice.Replier
now func() time.Time
weatherProvider weather.Provider
weatherLocation string // default location for weather queries
// pending destructive-act confirmation. A destructive act replies with a
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
// the y/n answer. ponytail: single slot, single-user box — a second act
@@ -426,6 +444,22 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
return f.Format(values, date)
}
// Weather questions
if isWeatherQuery(dec.Utterance) {
loc := extractWeatherLocation(dec.Utterance, h.weatherLocation)
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
if errors.Is(err, weather.ErrNotConfigured) {
return "погода не настроена."
}
if err != nil {
log.Printf("voice: weather: %v", err)
return "не получилось узнать погоду."
}
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition)
}
vec, err := h.embedder.Embed(ctx, dec.Utterance)
if err != nil {
log.Printf("voice: embed query: %v", err)
@@ -558,8 +592,6 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
dow := ruWeekdays[now.Weekday()]
month := ruMonths[now.Month()-1]
return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year())
case strings.Contains(u, "погод") || strings.Contains(u, "градус") || strings.Contains(u, "дожд") || strings.Contains(u, "холод") || strings.Contains(u, "тепл"):
return "погода пока не подключена — нужен внешний сервис."
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
return "присутствие пока не подключено к голосовому запросу."
case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"):
@@ -846,6 +878,49 @@ func firstLine(s string) string {
return ""
}
// 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")
}
// extractWeatherLocation parses a location from the utterance, or falls back
// to the configured default. Very basic: just checks for known city names.
func extractWeatherLocation(u, defaultLoc string) string {
lower := strings.ToLower(u)
cities := map[string]string{
"москв": "Moscow",
"moscow": "Moscow",
"питер": "Saint Petersburg",
"spb": "Saint Petersburg",
"петербур": "Saint Petersburg",
"лондон": "London",
"london": "London",
"париж": "Paris",
"paris": "Paris",
"берлин": "Berlin",
"berlin": "Berlin",
"ньйорк": "New York",
"new york": "New York",
}
for substr, name := range cities {
if strings.Contains(lower, substr) {
return name
}
}
if defaultLoc != "" {
return defaultLoc
}
return "Moscow"
}
func jsonStringImpl(s string) string {
// minimal JSON string escape — quotes + backslash + control chars.
// adequate for the reminder payload's text field; not a general JSON