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
+11
View File
@@ -169,6 +169,11 @@ type VoiceConfig struct {
// Default 0.35 if unset.
RouterThreshold float64 `json:"router_threshold,omitempty"`
// Weather — the weather provider config. nil ⇒ the daemon wires
// the stub provider (returns ErrNotConfigured — "погода не настроена").
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
Weather *WeatherConfig `json:"weather,omitempty"`
// Tools — the enabled act allowlist. Each is a spoken verb → argv the
// executor runs (args from the utterance appended). Editing this set is the
// human-only "enable" act (per spec); maven can't add to it from a request.
@@ -179,6 +184,12 @@ type VoiceConfig struct {
ToolTimeout Duration `json:"tool_timeout,omitempty"`
}
// WeatherConfig configures the weather provider for voice queries.
type WeatherConfig struct {
Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub
DefaultLocation string `json:"default_location,omitempty"` // e.g. "Moscow"
}
// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is
// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that
// must not fire from the voice path (they need a confirm on an authed surface).
+16
View File
@@ -155,6 +155,22 @@ func TestVoiceSttTtsConfigParsed(t *testing.T) {
}
}
func TestWeatherConfig(t *testing.T) {
// Weather block with provider + default location → OK
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","weather":{"provider":"open-meteo","default_location":"Moscow"}}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load with weather config: %v", err)
}
}
func TestWeatherConfigNilOK(t *testing.T) {
// No weather block → OK (stub)
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load without weather config: %v", err)
}
}
func TestDurationRoundTrip(t *testing.T) {
d := Duration(15 * time.Minute)
b, err := d.MarshalJSON()
+129
View File
@@ -0,0 +1,129 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type OpenMeteoProvider struct {
httpClient *http.Client
}
func NewOpenMeteoProvider() *OpenMeteoProvider {
return &OpenMeteoProvider{
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (p *OpenMeteoProvider) SetClient(c *http.Client) {
p.httpClient = c
}
type geoResult struct {
Name string `json:"name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Country string `json:"country"`
}
type geoResponse struct {
Results []geoResult `json:"results"`
}
type currentWeather struct {
Temperature float64 `json:"temperature"`
WMO int `json:"weathercode"`
WindSpeed float64 `json:"windspeed"`
}
type forecastResponse struct {
CurrentWeather currentWeather `json:"current_weather"`
}
var wmoCodes = map[int]string{
0: "ясно",
1: "преимущественно ясно",
2: "облачно",
3: "пасмурно",
45: "туман",
51: "морось",
61: "дождь",
71: "снег",
95: "гроза",
}
func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) (Weather, error) {
lat, lon, name, err := p.geocode(ctx, location)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: geocode: %w", err)
}
u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f&current_weather=true", lat, lon)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: request: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: http: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: read: %w", err)
}
var forecast forecastResponse
if err := json.Unmarshal(body, &forecast); err != nil {
return Weather{}, fmt.Errorf("open-meteo: decode: %w", err)
}
condition := wmoCodes[forecast.CurrentWeather.WMO]
if condition == "" {
condition = "неизвестно"
}
return Weather{
Location: name,
Temperature: forecast.CurrentWeather.Temperature,
Condition: condition,
}, nil
}
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, 0, "", err
}
resp, err := p.httpClient.Do(req)
if err != nil {
return 0, 0, "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, "", err
}
var geo geoResponse
if err := json.Unmarshal(body, &geo); err != nil {
return 0, 0, "", err
}
if len(geo.Results) == 0 {
return 0, 0, "", fmt.Errorf("location %q not found", location)
}
r := geo.Results[0]
return r.Latitude, r.Longitude, r.Name, nil
}
+85
View File
@@ -0,0 +1,85 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestOpenMeteoProvider(t *testing.T) {
var geoReqCount, weatherReqCount int
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/search" {
geoReqCount++
json.NewEncoder(w).Encode(geoResponse{
Results: []geoResult{{
Name: "Moscow", Latitude: 55.7558, Longitude: 37.6173, Country: "Russia",
}},
})
return
}
if r.URL.Path == "/v1/forecast" {
weatherReqCount++
json.NewEncoder(w).Encode(forecastResponse{
CurrentWeather: currentWeather{
Temperature: 22.5,
WMO: 0,
WindSpeed: 3.2,
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mock.Close()
p := NewOpenMeteoProvider()
transport := &mockTransport{
geoURL: mock.URL + "/v1/search",
forecastURL: mock.URL + "/v1/forecast",
}
p.SetClient(&http.Client{Transport: transport})
w, err := p.CurrentWeather(context.Background(), "Moscow")
if err != nil {
t.Fatalf("CurrentWeather: %v", err)
}
if w.Location != "Moscow" {
t.Errorf("location = %q, want Moscow", w.Location)
}
if w.Temperature != 22.5 {
t.Errorf("temperature = %f, want 22.5", w.Temperature)
}
if w.Condition != "ясно" {
t.Errorf("condition = %q, want ясно", w.Condition)
}
}
type mockTransport struct {
geoURL string
forecastURL string
}
func (t *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) {
var targetURL string
if r.URL.Host == "geocoding-api.open-meteo.com" {
targetURL = t.geoURL + "?" + r.URL.RawQuery
} else if r.URL.Host == "api.open-meteo.com" {
targetURL = t.forecastURL + "?" + r.URL.RawQuery
} else {
return nil, fmt.Errorf("unexpected host: %s", r.URL.Host)
}
req, _ := http.NewRequestWithContext(r.Context(), r.Method, targetURL, nil)
return http.DefaultTransport.RoundTrip(req)
}
func TestStubProvider(t *testing.T) {
p := NewStubProvider()
_, err := p.CurrentWeather(context.Background(), "Moscow")
if err != ErrNotConfigured {
t.Fatalf("StubProvider: want ErrNotConfigured, got %v", err)
}
}
+26
View File
@@ -0,0 +1,26 @@
package weather
import (
"context"
"errors"
)
var ErrNotConfigured = errors.New("weather: not configured")
type Weather struct {
Location string `json:"location"`
Temperature float64 `json:"temperature"`
Condition string `json:"condition"`
}
type Provider interface {
CurrentWeather(ctx context.Context, location string) (Weather, error)
}
type StubProvider struct{}
func NewStubProvider() *StubProvider { return &StubProvider{} }
func (s *StubProvider) CurrentWeather(_ context.Context, location string) (Weather, error) {
return Weather{}, ErrNotConfigured
}