Files
bdeb1337 d7342d6096 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.
2026-10-11 19:29:58 +02:00

142 lines
5.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}