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
+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
}