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:
+86
-11
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user