From e030466cac3ba1359d537394196a957e7f387bae Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 04:15:54 +0400 Subject: [PATCH] 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 --- cmd/mavend/voice.go | 97 +++++++++++++++++++--- internal/config/config.go | 11 +++ internal/config/config_test.go | 16 ++++ internal/weather/openmeteo.go | 129 +++++++++++++++++++++++++++++ internal/weather/openmeteo_test.go | 85 +++++++++++++++++++ internal/weather/weather.go | 26 ++++++ 6 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 internal/weather/openmeteo.go create mode 100644 internal/weather/openmeteo_test.go create mode 100644 internal/weather/weather.go diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index aaf6148..10f9183 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 56422a6..c2cca42 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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). diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2cae5c0..c86f8f4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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() diff --git a/internal/weather/openmeteo.go b/internal/weather/openmeteo.go new file mode 100644 index 0000000..2bc246e --- /dev/null +++ b/internal/weather/openmeteo.go @@ -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¤t_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 +} diff --git a/internal/weather/openmeteo_test.go b/internal/weather/openmeteo_test.go new file mode 100644 index 0000000..e2c1917 --- /dev/null +++ b/internal/weather/openmeteo_test.go @@ -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) + } +} diff --git a/internal/weather/weather.go b/internal/weather/weather.go new file mode 100644 index 0000000..71a2f45 --- /dev/null +++ b/internal/weather/weather.go @@ -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 +}