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,169 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/config"
|
||||
)
|
||||
|
||||
// fakeUpstreams serves Open-Meteo (real Ghent fixtures), Overpass and an LLM
|
||||
// that echoes the start of its system prompt, recording what it was sent.
|
||||
func fakeUpstreams(t *testing.T, prompts *[]string) *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/forecast":
|
||||
http.ServeFile(w, r, "../weather/testdata/forecast.json")
|
||||
case "/v1/search":
|
||||
http.ServeFile(w, r, "../weather/testdata/geocode.json")
|
||||
case "/overpass":
|
||||
fmt.Fprint(w, `{"elements":[{"type":"relation","id":7,"tags":{"route":"foot","name":"Bourgoyenpad","roundtrip":"yes"},
|
||||
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.775}]}]}]}`)
|
||||
case "/brouter":
|
||||
http.ServeFile(w, r, "../routes/testdata/brouter.json")
|
||||
case "/llm/models":
|
||||
fmt.Fprint(w, `{"data":[{"id":"fake-gemma"}]}`)
|
||||
case "/llm/chat/completions":
|
||||
var req struct {
|
||||
Messages []struct{ Role, Content string }
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
*prompts = append(*prompts, req.Messages[0].Content+"\n---\n"+req.Messages[len(req.Messages)-1].Content)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
for _, part := range []string{"**Go for a run** ", "<script>x</script>"} {
|
||||
b, _ := json.Marshal(part)
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", b)
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newTestApp(t *testing.T, prompts *[]string) (*httptest.Server, *http.Client) {
|
||||
t.Helper()
|
||||
up := fakeUpstreams(t, prompts)
|
||||
cfg := config.Config{
|
||||
BaseURL: up.URL + "/llm",
|
||||
Timeout: 10 * time.Second,
|
||||
OverpassURL: up.URL + "/overpass",
|
||||
BRouterURL: up.URL + "/brouter",
|
||||
ForecastURL: up.URL + "/v1/forecast",
|
||||
GeocodeURL: up.URL + "/v1/search",
|
||||
}
|
||||
app := httptest.NewServer(New(cfg, "", slog.New(slog.NewTextHandler(io.Discard, nil))).Handler())
|
||||
t.Cleanup(app.Close)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return app, &http.Client{Jar: jar}
|
||||
}
|
||||
|
||||
var streamURL = regexp.MustCompile(`sse-connect="(/stream/[0-9a-f]+)"`)
|
||||
|
||||
func do(t *testing.T, c *http.Client, method, u string, form url.Values) string {
|
||||
t.Helper()
|
||||
var resp *http.Response
|
||||
var err error
|
||||
if method == http.MethodPost {
|
||||
resp, err = c.PostForm(u, form)
|
||||
} else {
|
||||
resp, err = c.Get(u)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestPlanFlow(t *testing.T) {
|
||||
var prompts []string
|
||||
app, c := newTestApp(t, &prompts)
|
||||
|
||||
page := do(t, c, "GET", app.URL+"/", nil)
|
||||
if !strings.Contains(page, "Get me outside") {
|
||||
t.Fatal("index lacks the form")
|
||||
}
|
||||
|
||||
plan := do(t, c, "POST", app.URL+"/plan", url.Values{
|
||||
"place": {"Gent"}, "activity": {"run"}, "intensity": {"easy"}, "minutes": {"30"}, "note": {"bad knee"},
|
||||
})
|
||||
for _, want := range []string{"Ghent, Belgium", "15°", "great", `hx-get="/routes"`} {
|
||||
if !strings.Contains(plan, want) {
|
||||
t.Errorf("plan lacks %q", want)
|
||||
}
|
||||
}
|
||||
m := streamURL.FindStringSubmatch(plan)
|
||||
if m == nil {
|
||||
t.Fatal("plan has no streaming reply")
|
||||
}
|
||||
|
||||
// The first reply waits for routes, so the model sees them.
|
||||
stream := do(t, c, "GET", app.URL+m[1], nil)
|
||||
if !strings.Contains(stream, "event: done") || !strings.Contains(stream, "<strong>Go for a run</strong>") {
|
||||
t.Errorf("stream = %s", stream)
|
||||
}
|
||||
if strings.Contains(stream, "<script>") {
|
||||
t.Error("model output was not escaped")
|
||||
}
|
||||
if len(prompts) != 1 {
|
||||
t.Fatalf("llm called %d times", len(prompts))
|
||||
}
|
||||
for _, want := range []string{"Ghent, Belgium", `"bad knee"`, `"Bourgoyenpad": signposted`, "a loop from the user's door", "m of climbing", "The app's pick: running", "What should I do right now?"} {
|
||||
if !strings.Contains(prompts[0], want) {
|
||||
t.Errorf("prompt lacks %q:\n%s", want, prompts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnecting to a finished reply doesn't generate it again.
|
||||
do(t, c, "GET", app.URL+m[1], nil)
|
||||
if len(prompts) != 1 {
|
||||
t.Error("reply was generated twice")
|
||||
}
|
||||
|
||||
routes := do(t, c, "GET", app.URL+"/routes", nil)
|
||||
if !strings.Contains(routes, "Bourgoyenpad") || !strings.Contains(routes, "/gpx/7") || !strings.Contains(routes, "from your door") || !strings.Contains(routes, "/gpx/-1") {
|
||||
t.Errorf("routes card = %s", routes)
|
||||
}
|
||||
if gpx := do(t, c, "GET", app.URL+"/gpx/7", nil); !strings.Contains(gpx, "<trkpt") {
|
||||
t.Errorf("gpx = %s", gpx)
|
||||
}
|
||||
|
||||
// Follow-ups keep the plan and get the brevity hint.
|
||||
ask := do(t, c, "POST", app.URL+"/ask", url.Values{"message": {"make it harder"}})
|
||||
m = streamURL.FindStringSubmatch(ask)
|
||||
if m == nil || !strings.Contains(ask, "make it harder") {
|
||||
t.Fatalf("ask = %s", ask)
|
||||
}
|
||||
do(t, c, "GET", app.URL+m[1], nil)
|
||||
if len(prompts) != 2 || !strings.Contains(prompts[1], "make it harder\n\n(Answer in a few short lines") {
|
||||
t.Errorf("follow-up prompt = %q", prompts[len(prompts)-1])
|
||||
}
|
||||
|
||||
// Reloading the page shows the plan and the chat again.
|
||||
page = do(t, c, "GET", app.URL+"/", nil)
|
||||
if !strings.Contains(page, "make it harder") || !strings.Contains(page, "Ghent, Belgium") {
|
||||
t.Error("reload lost the plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNeedsAPlace(t *testing.T) {
|
||||
var prompts []string
|
||||
app, c := newTestApp(t, &prompts)
|
||||
if got := do(t, c, "POST", app.URL+"/plan", url.Values{"minutes": {"60"}}); !strings.Contains(got, "Where are you?") {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user