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: ** **: 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") }