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
|
||||
|
||||
Reference in New Issue
Block a user