feat: workout-suggester, weather-aware workouts from a local Gemma
Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway fallback), scores run/ride/walk with plain rules, and has a local open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write the plan. Routes are loops from your door (BRouter) plus signposted OpenStreetMap routes (Overpass), with GPX export.
This commit is contained in:
42 files changed
+7219
No files matched your search
@@ -0,0 +1,141 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// Plan is everything the model needs to know, gathered before it speaks.
|
||||
type Plan struct {
|
||||
Place string
|
||||
At routes.Point // exact; only used on this machine
|
||||
Forecast weather.Forecast
|
||||
Request Request
|
||||
Assessment Assessment
|
||||
RoutesLoaded bool
|
||||
Routes []routes.Suggestion
|
||||
Junction *JunctionInfo
|
||||
RoutesErr string
|
||||
}
|
||||
|
||||
// JunctionInfo is the nearest node-network junction to start from.
|
||||
type JunctionInfo struct {
|
||||
Ref string
|
||||
Kind routes.Kind
|
||||
Km float64
|
||||
}
|
||||
|
||||
// FirstQuestion is the hidden user turn that asks for the plan.
|
||||
const FirstQuestion = "What should I do right now?"
|
||||
|
||||
// FollowUpHint is added to follow-up questions sent to the model.
|
||||
const FollowUpHint = "Answer in a few short lines, not the full plan format. Only change what I asked."
|
||||
|
||||
const persona = `You are the coach inside "workout-suggester", an app that gets people off their screen and outside.
|
||||
The app has already checked the weather and scored each activity with fixed rules. Trust the FACTS below and never invent other numbers, temperatures, times or route names.
|
||||
Be warm, direct and brief: under 180 words, Markdown, no tables, no preamble.`
|
||||
|
||||
const firstReplyFormat = `Answer the first question in exactly this shape:
|
||||
**<emoji> <the pick, e.g. "Go for a run">**: one sentence on why, citing the weather.
|
||||
**When:** now, or the better window from the FACTS if there is one.
|
||||
**The session:** 3–5 bullets: warm-up, a main set that matches the intensity and the minutes, cool-down. Hard = intervals or tempo; easy = conversational pace.
|
||||
**Route:** recommend the first listed route by its name and say how to use it (from the door, laps, out and back); mention its climb if it has one and fits the user's note. If no routes are listed, suggest an easy out-and-back from the door.
|
||||
**Wear:** one line for this weather.
|
||||
**Plan B:** one line.
|
||||
|
||||
If the pick is an indoor workout, give a concrete HIIT, bodyweight or gym session for the minutes and intensity under **The session**, skip **Route** and **Wear**, and say under **When** whether it gets nicer outside later.`
|
||||
|
||||
// SystemPrompt is the persona plus the facts and the answer format.
|
||||
func (p Plan) SystemPrompt() string {
|
||||
return persona + "\n\n" + p.Facts() + "\n\n" + firstReplyFormat
|
||||
}
|
||||
|
||||
// Facts is a compact, model-friendly summary of the plan.
|
||||
func (p Plan) Facts() string {
|
||||
var b strings.Builder
|
||||
f, a, r := p.Forecast, p.Assessment, p.Request
|
||||
w := func(format string, args ...any) { fmt.Fprintf(&b, format+"\n", args...) }
|
||||
desc, _ := weather.Describe(f.Now.Code)
|
||||
|
||||
w("FACTS")
|
||||
where := p.Place
|
||||
if where == "" {
|
||||
where = "the user's location"
|
||||
}
|
||||
w("- Place: %s. Local time: %s. Sunset: %s.", where, f.Now.Time.Format("Mon 15:04"), f.Sunset.Format("15:04"))
|
||||
gusts := fmt.Sprintf("gusts %.0f km/h", f.Now.Gusts)
|
||||
if f.GustsEstimated {
|
||||
gusts = fmt.Sprintf("gusts around %.0f km/h (estimated)", f.Now.Gusts)
|
||||
}
|
||||
chance := ""
|
||||
if f.Now.PrecipProb >= 0 {
|
||||
chance = fmt.Sprintf(", rain chance %d%%", f.Now.PrecipProb)
|
||||
}
|
||||
w("- Weather now: %.0f°C (feels like %.0f°C), %s, wind %.0f km/h from the %s, %s, rain %.1f mm%s.",
|
||||
f.Now.Temp, f.Now.Feels, desc, f.Now.Wind, weather.Compass(f.Now.WindDirection), gusts, f.Now.Precip, chance)
|
||||
w("- User wants: %s, %s intensity, %d minutes.", r.Activity.Noun(), r.Intensity, r.Minutes)
|
||||
if r.Note != "" {
|
||||
w("- User note: %q", r.Note)
|
||||
}
|
||||
w("- Scores for the next %d minutes (0-100, rules-based):", r.Minutes)
|
||||
for _, s := range a.Scores {
|
||||
why := "good conditions"
|
||||
if len(s.Reasons) > 0 {
|
||||
why = strings.Join(s.Reasons, "; ")
|
||||
}
|
||||
w(" - %s: %d (%s): %s", s.Activity.Noun(), s.Value, s.Verdict(), why)
|
||||
}
|
||||
w("- The app's pick: %s.", a.Pick.Noun())
|
||||
if r.Activity != Any && r.Activity != a.Pick {
|
||||
w("- The user asked for %s but the weather makes it a poor choice right now; explain kindly.", r.Activity.Noun())
|
||||
}
|
||||
if a.Later != nil {
|
||||
w("- Better window: %s scores %d from %s.", a.Later.Activity.Noun(), a.Later.Score, a.Later.Start.Format("15:04"))
|
||||
} else {
|
||||
w("- Better window later today: none, now is as good as it gets.")
|
||||
}
|
||||
if a.TargetKm > 0 {
|
||||
w("- Target: about %.1f km at %s.", a.TargetKm, a.Pace)
|
||||
}
|
||||
if a.Pick != Indoor {
|
||||
switch {
|
||||
case len(p.Routes) > 0:
|
||||
w("- Routes, best fit first:")
|
||||
for i, s := range p.Routes {
|
||||
r := s.Route
|
||||
if r.Generated {
|
||||
extra := fmt.Sprintf(", %d m of climbing", r.AscentM)
|
||||
if r.PavedPct >= 0 {
|
||||
extra += fmt.Sprintf(", %d%% paved", r.PavedPct)
|
||||
}
|
||||
if r.IntoWind(f.Now.WindDirection) {
|
||||
extra += ", heads out into the wind so the way home has a tailwind"
|
||||
}
|
||||
w(" %d. %q: a loop from the user's door, %.1f km%s.", i+1, r.Name, r.LengthKm, extra)
|
||||
continue
|
||||
}
|
||||
loop := ""
|
||||
if r.Loop {
|
||||
loop = ", loop"
|
||||
}
|
||||
w(" %d. %q: signposted route, %.1f km%s, %.1f km from the user; suggested: %s (≈%.1f km).",
|
||||
i+1, r.Name, r.LengthKm, loop, s.FromYouKm, s.Describe(), s.TotalKm)
|
||||
}
|
||||
case p.RoutesErr != "":
|
||||
w("- Routes: the map service couldn't be reached, so no routes are listed.")
|
||||
default:
|
||||
w("- Routes: none found nearby.")
|
||||
}
|
||||
if j := p.Junction; j != nil {
|
||||
kind := "walking"
|
||||
if j.Kind == routes.Bike {
|
||||
kind = "cycling"
|
||||
}
|
||||
w("- Nearest %s junction (knooppunt): number %s, %.1f km away.", kind, j.Ref, j.Km)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
Reference in new issue
Block a user