lots of stuff, don't truly remember

This commit is contained in:
Blake Ridgway
2026-05-17 20:39:47 -05:00
parent 178ffb3425
commit dc4fe558b7
35 changed files with 3501 additions and 112 deletions

133
internal/ai/handler.go Normal file
View File

@@ -0,0 +1,133 @@
package ai
import (
"encoding/json"
"log"
"net/http"
"rideaware/internal/config"
"rideaware/internal/middleware"
)
type Handler struct {
service *Service
}
func NewHandler() *Handler {
return &Handler{
service: NewService(),
}
}
// GenerateRecommendations POST /api/protected/ai/generate
func (h *Handler) GenerateRecommendations(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req GenerateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate request
if err := validateGenerateRequest(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
log.Printf("[AI] User %d requesting %d-day plan (focus: %v, intensity: %s)",
claims.UserID, req.PlanDuration, req.FocusAreas, req.IntensityLevel)
// Generate workouts
response, err := h.service.GenerateWorkouts(claims.UserID, req)
if err != nil {
log.Printf("[AI] Generation error for user %d: %v", claims.UserID, err)
respondError(w, http.StatusInternalServerError, "failed to generate workouts: "+err.Error())
return
}
log.Printf("[AI] Successfully generated %d workouts for user %d (total TSS: %.1f)",
len(response.Workouts), claims.UserID, response.TotalTSS)
respondJSON(w, http.StatusOK, response)
}
// ScheduleRecommendations POST /api/protected/ai/schedule
func (h *Handler) ScheduleRecommendations(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req struct {
RecommendationID uint `json:"recommendation_id"`
WorkoutIndices []int `json:"workout_indices"` // Which workouts to schedule
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request")
return
}
if req.RecommendationID == 0 {
respondError(w, http.StatusBadRequest, "recommendation_id is required")
return
}
log.Printf("[AI] User %d scheduling %d workouts from recommendation %d",
claims.UserID, len(req.WorkoutIndices), req.RecommendationID)
// Schedule selected workouts
workouts, err := h.service.ScheduleWorkouts(claims.UserID, req.RecommendationID, req.WorkoutIndices)
if err != nil {
log.Printf("[AI] Schedule error for user %d: %v", claims.UserID, err)
respondError(w, http.StatusInternalServerError, err.Error())
return
}
log.Printf("[AI] Successfully scheduled %d workouts for user %d", len(workouts), claims.UserID)
respondJSON(w, http.StatusCreated, map[string]interface{}{
"scheduled_count": len(workouts),
"workouts": workouts,
})
}
// GetRecommendationHistory GET /api/protected/ai/history
func (h *Handler) GetRecommendationHistory(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
history, err := h.service.GetUserRecommendations(claims.UserID, 10)
if err != nil {
log.Printf("[AI] Failed to fetch history for user %d: %v", claims.UserID, err)
respondError(w, http.StatusInternalServerError, "failed to fetch history")
return
}
// Return empty array instead of null
if history == nil {
history = []AIRecommendation{}
}
respondJSON(w, http.StatusOK, history)
}
// Helper functions
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}

170
internal/ai/model.go Normal file
View File

@@ -0,0 +1,170 @@
package ai
import (
"database/sql/driver"
"time"
"rideaware/internal/workout"
)
// GenerateRequest is the user's request for AI workout generation
type GenerateRequest struct {
PlanDuration int `json:"plan_duration"` // Number of days (1, 7, 14, 28)
FocusAreas []string `json:"focus_areas"` // ["endurance", "threshold", "vo2max", "recovery", "sprint", "sweet_spot"]
IntensityLevel string `json:"intensity_level"` // "easy", "moderate", "hard"
WeeklyHours int `json:"weekly_hours"` // Available training hours per week
StartDate string `json:"start_date"` // YYYY-MM-DD
IncludeRest bool `json:"include_rest"` // Whether to include rest days
TargetEventID *uint `json:"target_event_id"` // Optional target event to periodize for
}
// GenerateResponse contains AI-generated workouts
type GenerateResponse struct {
Workouts []AIWorkout `json:"workouts"`
Rationale string `json:"rationale"` // AI's explanation
TotalTSS float64 `json:"total_tss"`
RecommendationID uint `json:"recommendation_id"`
}
// AIWorkout represents a single AI-generated workout
type AIWorkout struct {
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
ScheduledDate string `json:"scheduled_date"`
Duration int `json:"duration"` // seconds
Segments []workout.WorkoutSegment `json:"segments"`
EstimatedTSS float64 `json:"estimated_tss"`
Notes string `json:"notes"`
}
// AIRecommendation database model
type AIRecommendation struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
PromptContext JSONB `gorm:"type:jsonb" json:"prompt_context"`
AIResponse string `json:"ai_response"`
GeneratedWorkouts JSONB `gorm:"type:jsonb" json:"generated_workouts"`
Parameters JSONB `gorm:"type:jsonb" json:"parameters"`
Status string `gorm:"default:'generated'" json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func (AIRecommendation) TableName() string {
return "ai_recommendations"
}
// JSONB type for PostgreSQL JSONB columns
type JSONB struct {
Data []byte
}
// Scan implements sql.Scanner interface
func (j *JSONB) Scan(value interface{}) error {
if value == nil {
j.Data = []byte("{}")
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
j.Data = bytes
return nil
}
// Value implements driver.Valuer interface
func (j JSONB) Value() (driver.Value, error) {
if len(j.Data) == 0 {
return []byte("{}"), nil
}
return j.Data, nil
}
// MarshalJSON implements json.Marshaler
func (j JSONB) MarshalJSON() ([]byte, error) {
if len(j.Data) == 0 {
return []byte("{}"), nil
}
return j.Data, nil
}
// UnmarshalJSON implements json.Unmarshaler
func (j *JSONB) UnmarshalJSON(data []byte) error {
j.Data = data
return nil
}
// UserContext contains all user data sent to AI
type UserContext struct {
FTP int `json:"ftp"`
MaxHR int `json:"max_hr"`
RestingHR int `json:"resting_hr"`
Weight float64 `json:"weight"`
TrainingGoal string `json:"training_goal"`
WeeklyHours int `json:"weekly_hours"`
RecentWorkouts []RecentWorkoutSummary `json:"recent_workouts"`
TrainingLoad TrainingLoadSummary `json:"training_load"`
UpcomingEvents []UpcomingEventSummary `json:"upcoming_events,omitempty"`
TargetEvent *UpcomingEventSummary `json:"target_event,omitempty"`
Nutrition *NutritionContext `json:"nutrition,omitempty"`
}
// NutritionContext contains nutrition data for AI
type NutritionContext struct {
Goal string `json:"goal"`
DailyCalories int `json:"daily_calories"`
ProteinG int `json:"protein_g"`
CarbsG int `json:"carbs_g"`
FatG int `json:"fat_g"`
DietaryPref string `json:"dietary_preference"`
}
// UpcomingEventSummary summarizes an upcoming race/event for AI context
type UpcomingEventSummary struct {
Name string `json:"name"`
Date string `json:"date"`
EventType string `json:"event_type"`
Distance float64 `json:"distance"`
Priority string `json:"priority"`
DaysAway int `json:"days_away"`
}
// RecentWorkoutSummary summarizes a completed workout
type RecentWorkoutSummary struct {
Date string `json:"date"`
Type string `json:"type"`
Duration int `json:"duration"`
AvgPower int `json:"avg_power"`
TSS float64 `json:"tss"`
}
// TrainingLoadSummary contains CTL/ATL/TSB metrics
type TrainingLoadSummary struct {
CTL float64 `json:"ctl"` // Chronic Training Load (42-day EMA)
ATL float64 `json:"atl"` // Acute Training Load (7-day EMA)
TSB float64 `json:"tsb"` // Training Stress Balance (CTL - ATL)
}
// DeepSeek API structures
type DeepSeekRequest struct {
Model string `json:"model"`
Messages []DeepSeekMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type DeepSeekMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type DeepSeekResponse struct {
Choices []struct {
Message DeepSeekMessage `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}

214
internal/ai/prompt.go Normal file
View File

@@ -0,0 +1,214 @@
package ai
import (
"encoding/json"
"fmt"
"strings"
)
// BuildSystemPrompt creates the system message for DeepSeek
func BuildSystemPrompt() string {
return `You are an expert cycling coach with deep knowledge of power-based training, periodization, and workout design.
Your task is to generate structured cycling workouts based on user data and goals. You must respond ONLY with valid JSON.
POWER ZONES (% of FTP):
- Recovery: 30-55% FTP
- Endurance/Zone 2: 56-75% FTP
- Tempo: 76-87% FTP
- Sweet Spot: 88-94% FTP
- Threshold/FTP: 95-105% FTP
- VO2max: 106-120% FTP
- Anaerobic: 121-150% FTP
- Neuromuscular: >150% FTP
WORKOUT STRUCTURE:
- All workouts must have warmup, main intervals, and cooldown
- Duration is in seconds
- Power is expressed as decimal (0.65 = 65% FTP, 1.0 = 100% FTP)
- Segment types: "warmup", "steadystate", "interval", "ramp", "cooldown", "rest", "freeride"
- For steady state efforts, use "power" field
- For variable efforts, use "power_low" and "power_high" fields
TSS CALCULATION:
TSS = (duration_seconds × NP² / FTP²) / 36
Approximate: duration_minutes × intensity_factor²
TRAINING PRINCIPLES:
1. Progressive overload within user's current fitness
2. Balance intensity and volume
3. Include recovery when needed
4. Respect weekly hour constraints
5. Build on recent training load (CTL/ATL/TSB)
6. Vary workouts to prevent monotony
7. Include appropriate warmup and cooldown for all workouts
EVENT-BASED PERIODIZATION (when a target event is provided):
- Structure the plan around the target event date using standard cycling periodization:
- BUILD phase: Progressive volume/intensity increase (furthest from event)
- PEAK phase: Highest intensity, race-specific intervals
- TAPER phase: Reduce volume while maintaining intensity
- RACE DAY: Generate a "Race" type workout on the event date with pre-race notes
- RECOVERY: Easy rides after race day
- A-priority race: 7-10 day taper, reduce volume 30-50%, maintain intensity
- B-priority race: 3-5 day lighter taper
- C-priority race: No taper, treat as training race
- On race day, create a workout with type "Race" and include race distance/event details in notes
- If multiple events exist, avoid scheduling hard workouts within 2 days of any event
NUTRITION GUIDANCE (when nutrition context is provided):
- Include brief fueling notes in the "notes" field for each workout:
- Pre-ride: what to eat 1-2 hours before (e.g., "Pre: oatmeal + banana, 400kcal")
- During: fueling for rides >60min (e.g., "During: 60g carbs/hr, sports drink")
- Post-ride: recovery nutrition within 30min (e.g., "Post: protein shake + rice, 500kcal")
- For easy/recovery rides: lighter fueling notes
- For hard/long rides: emphasize carb loading and during-ride nutrition
- Keep fueling notes concise (one line each)
OUTPUT FORMAT (JSON only - BE CONCISE):
{
"workouts": [
{
"title": "Workout Name",
"description": "Brief description",
"type": "Endurance|Tempo|Threshold|VO2 Max|Recovery|Sprint",
"scheduled_date": "YYYY-MM-DD",
"duration": 3600,
"segments": [
{"type": "warmup", "duration": 600, "power_low": 0.40, "power_high": 0.65},
{"type": "steadystate", "duration": 2400, "power": 0.65, "cadence": 85},
{"type": "cooldown", "duration": 600, "power_low": 0.65, "power_high": 0.40}
],
"estimated_tss": 45.0,
"notes": "Brief coaching note"
}
],
"rationale": "Brief plan overview"
}
IMPORTANT: Keep descriptions and notes VERY SHORT (max 10 words each). Response must be valid, complete JSON.`
}
// BuildUserPrompt creates the user message with context
func BuildUserPrompt(ctx UserContext, req GenerateRequest) string {
contextJSON, _ := json.MarshalIndent(ctx, "", " ")
focusAreasStr := strings.Join(req.FocusAreas, ", ")
// Build event context section
eventSection := ""
if ctx.TargetEvent != nil {
eventSection = fmt.Sprintf(`
TARGET EVENT:
- Name: %s
- Date: %s (%d days away)
- Type: %s
- Distance: %.0f km
- Priority: %s-race
- IMPORTANT: Periodize the plan to peak for this event. Apply appropriate taper based on priority.
On event day (%s), generate a "Race" type workout with event details in notes.
`,
ctx.TargetEvent.Name,
ctx.TargetEvent.Date,
ctx.TargetEvent.DaysAway,
ctx.TargetEvent.EventType,
ctx.TargetEvent.Distance,
ctx.TargetEvent.Priority,
ctx.TargetEvent.Date,
)
}
if len(ctx.UpcomingEvents) > 0 {
eventSection += "\nUPCOMING EVENTS (avoid hard workouts within 2 days of these):\n"
for _, ev := range ctx.UpcomingEvents {
eventSection += fmt.Sprintf("- %s (%s, %s-race, %d days away)\n",
ev.Name, ev.Date, ev.Priority, ev.DaysAway)
}
}
// Build nutrition context section
nutritionSection := ""
if ctx.Nutrition != nil {
nutritionSection = fmt.Sprintf(`
NUTRITION CONTEXT:
- Goal: %s
- Daily Calorie Target: %d kcal (before workout additions)
- Macro Targets: Protein %dg, Carbs %dg, Fat %dg
- Dietary Preference: %s
- IMPORTANT: Include brief fueling notes (pre-ride, during, post-ride) in each workout's "notes" field. Adjust fueling intensity to match workout intensity.
`,
ctx.Nutrition.Goal,
ctx.Nutrition.DailyCalories,
ctx.Nutrition.ProteinG,
ctx.Nutrition.CarbsG,
ctx.Nutrition.FatG,
ctx.Nutrition.DietaryPref,
)
}
return fmt.Sprintf(`Generate a %d-day training plan with the following requirements:
USER CONTEXT:
%s
PLAN PARAMETERS:
- Duration: %d days
- Focus Areas: %s
- Intensity Level: %s
- Weekly Available Hours: %d
- Start Date: %s
- Include Rest Days: %v
%s%s
REQUIREMENTS:
1. Generate workouts that fit within %d weekly hours
2. Focus on: %s
3. Overall intensity: %s
4. Respect user's current fitness (FTP: %d, recent training load)
5. Include variety and progressive adaptation
6. Provide clear workout descriptions and coaching notes
7. Ensure total duration of segments matches workout duration
8. Use appropriate power zones for each workout type
9. Include proper warmup and cooldown for every workout
IMPORTANT:
- Return ONLY valid JSON in the exact format specified in the system prompt
- Do not include any explanatory text before or after the JSON
- Ensure all dates are sequential starting from %s
- Calculate realistic TSS values for each workout
- Segment durations must sum to workout duration (in seconds)
- Power values should be between 0.30 and 2.50 (30%% to 250%% FTP)
Return ONLY valid JSON in the exact format specified in the system prompt.`,
req.PlanDuration,
string(contextJSON),
req.PlanDuration,
focusAreasStr,
req.IntensityLevel,
req.WeeklyHours,
req.StartDate,
req.IncludeRest,
eventSection,
nutritionSection,
req.WeeklyHours,
focusAreasStr,
req.IntensityLevel,
ctx.FTP,
req.StartDate,
)
}
// extractJSON attempts to extract JSON from response that might be wrapped in markdown
func extractJSON(response string) string {
// Remove markdown code blocks if present
if idx := strings.Index(response, "```json"); idx != -1 {
response = response[idx+7:]
} else if idx := strings.Index(response, "```"); idx != -1 {
response = response[idx+3:]
}
if idx := strings.Index(response, "```"); idx != -1 {
response = response[:idx]
}
return strings.TrimSpace(response)
}

66
internal/ai/repository.go Normal file
View File

@@ -0,0 +1,66 @@
package ai
import (
"encoding/json"
"rideaware/pkg/database"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
// SaveRecommendation stores the AI recommendation
func (r *Repository) SaveRecommendation(
userID uint,
userCtx UserContext,
params GenerateRequest,
aiResponse string,
generated *GenerateResponse,
) (uint, error) {
contextJSON, _ := json.Marshal(userCtx)
paramsJSON, _ := json.Marshal(params)
workoutsJSON, _ := json.Marshal(generated.Workouts)
rec := &AIRecommendation{
UserID: userID,
PromptContext: JSONB{Data: contextJSON},
AIResponse: aiResponse,
GeneratedWorkouts: JSONB{Data: workoutsJSON},
Parameters: JSONB{Data: paramsJSON},
Status: "generated",
}
if err := database.DB.Create(rec).Error; err != nil {
return 0, err
}
return rec.ID, nil
}
// GetRecommendation retrieves a recommendation by ID
func (r *Repository) GetRecommendation(id uint, userID uint) (*AIRecommendation, error) {
var rec AIRecommendation
err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&rec).Error
return &rec, err
}
// GetUserRecommendations fetches recent recommendations for a user
func (r *Repository) GetUserRecommendations(userID uint, limit int) ([]AIRecommendation, error) {
var recs []AIRecommendation
err := database.DB.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Find(&recs).Error
return recs, err
}
// UpdateRecommendationStatus marks a recommendation as scheduled/rejected
func (r *Repository) UpdateRecommendationStatus(id uint, status string) error {
return database.DB.Model(&AIRecommendation{}).
Where("id = ?", id).
Update("status", status).Error
}

478
internal/ai/service.go Normal file
View File

@@ -0,0 +1,478 @@
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"time"
"rideaware/internal/event"
"rideaware/internal/nutrition"
"rideaware/internal/stats"
"rideaware/internal/user"
"rideaware/internal/workout"
"rideaware/pkg/database"
)
type Service struct {
userRepo *user.Repository
statsRepo *stats.Repository
workoutRepo *workout.Repository
eventRepo *event.Repository
nutritionSvc *nutrition.Service
aiRepo *Repository
}
func NewService() *Service {
return &Service{
userRepo: user.NewRepository(),
statsRepo: stats.NewRepository(),
workoutRepo: workout.NewRepository(),
eventRepo: event.NewRepository(),
nutritionSvc: nutrition.NewService(),
aiRepo: NewRepository(),
}
}
// GenerateWorkouts orchestrates the AI generation process
func (s *Service) GenerateWorkouts(userID uint, req GenerateRequest) (*GenerateResponse, error) {
// 1. Gather user context
userCtx, err := s.buildUserContext(userID, req)
if err != nil {
return nil, fmt.Errorf("failed to build user context: %w", err)
}
// 2. Call DeepSeek API with retry logic
var aiResponse string
maxRetries := 2
for i := 0; i <= maxRetries; i++ {
if i > 0 {
log.Printf("[AI] Retry attempt %d/%d", i, maxRetries)
time.Sleep(time.Duration(i) * 2 * time.Second) // Exponential backoff
}
aiResponse, err = s.callDeepSeekAPI(userCtx, req)
if err == nil {
break
}
log.Printf("[AI] API call attempt %d failed: %v", i+1, err)
}
if err != nil {
return nil, fmt.Errorf("AI API call failed after %d attempts: %w", maxRetries+1, err)
}
// 3. Parse and validate response
genResponse, err := s.parseAIResponse(aiResponse)
if err != nil {
return nil, fmt.Errorf("failed to parse AI response: %w", err)
}
// 4. Fix duration mismatches (AI sometimes miscalculates)
s.fixWorkoutDurations(genResponse.Workouts)
// 5. Filter out incomplete workouts (defensive against malformed AI responses)
validWorkouts := make([]AIWorkout, 0, len(genResponse.Workouts))
for i, w := range genResponse.Workouts {
if w.Duration == 0 || len(w.Segments) == 0 || w.Title == "" {
log.Printf("[AI] Skipping incomplete workout %d (duration=%d, segments=%d, title=%q)",
i, w.Duration, len(w.Segments), w.Title)
continue
}
validWorkouts = append(validWorkouts, w)
}
genResponse.Workouts = validWorkouts
if len(validWorkouts) == 0 {
return nil, fmt.Errorf("no valid workouts generated by AI")
}
// 6. Validate workout structures
if err := validateWorkouts(genResponse.Workouts); err != nil {
return nil, fmt.Errorf("workout validation failed: %w", err)
}
// 7. Calculate total TSS
totalTSS := 0.0
for _, w := range genResponse.Workouts {
totalTSS += w.EstimatedTSS
}
genResponse.TotalTSS = totalTSS
// 8. Store recommendation in database
recID, err := s.aiRepo.SaveRecommendation(userID, userCtx, req, aiResponse, genResponse)
if err != nil {
return nil, fmt.Errorf("failed to save recommendation: %w", err)
}
genResponse.RecommendationID = recID
return genResponse, nil
}
// buildUserContext gathers all relevant user data
func (s *Service) buildUserContext(userID uint, req GenerateRequest) (UserContext, error) {
var ctx UserContext
// Get user profile
userObj, err := s.userRepo.GetUserByID(userID)
if err != nil {
return ctx, err
}
// Set default values if profile doesn't exist
ctx.FTP = 200 // default FTP
ctx.MaxHR = 180
ctx.RestingHR = 60
ctx.Weight = 70.0
ctx.WeeklyHours = req.WeeklyHours
// Override with actual profile data if available
if userObj.Profile != nil {
if userObj.Profile.FTP > 0 {
ctx.FTP = userObj.Profile.FTP
}
if userObj.Profile.MaxHR > 0 {
ctx.MaxHR = userObj.Profile.MaxHR
}
if userObj.Profile.RestingHR > 0 {
ctx.RestingHR = userObj.Profile.RestingHR
}
if userObj.Profile.Weight > 0 {
ctx.Weight = userObj.Profile.Weight
}
}
// Get recent workouts (last 30 days)
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -30)
workouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, startDate, endDate)
if err == nil {
ctx.RecentWorkouts = s.summarizeWorkouts(workouts, ctx.FTP)
} else {
ctx.RecentWorkouts = []RecentWorkoutSummary{}
}
// Get training load (CTL/ATL/TSB)
ctx.TrainingLoad = s.calculateTrainingLoad(userID, ctx.FTP)
// Get upcoming events
upcomingEvents, err := s.eventRepo.GetUpcomingEvents(userID, 5)
if err == nil && len(upcomingEvents) > 0 {
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, ev := range upcomingEvents {
evDate := time.Date(ev.EventDate.Year(), ev.EventDate.Month(), ev.EventDate.Day(), 0, 0, 0, 0, ev.EventDate.Location())
daysAway := int(evDate.Sub(now).Hours() / 24)
summary := UpcomingEventSummary{
Name: ev.Name,
Date: ev.EventDate.Format("2006-01-02"),
EventType: ev.EventType,
Distance: ev.Distance,
Priority: ev.Priority,
DaysAway: daysAway,
}
ctx.UpcomingEvents = append(ctx.UpcomingEvents, summary)
// If this is the target event, set it
if req.TargetEventID != nil && ev.ID == *req.TargetEventID {
target := summary
ctx.TargetEvent = &target
}
}
}
// Get nutrition context
nutritionTargets, err := s.nutritionSvc.GetTargets(userID)
if err == nil && nutritionTargets != nil && nutritionTargets.IsConfigured {
ctx.Nutrition = &NutritionContext{
Goal: nutritionTargets.NutritionGoal,
DailyCalories: nutritionTargets.DailyCalories,
ProteinG: nutritionTargets.Protein,
CarbsG: nutritionTargets.Carbs,
FatG: nutritionTargets.Fat,
DietaryPref: nutritionTargets.DietaryPref,
}
}
return ctx, nil
}
// summarizeWorkouts converts workouts to summaries
func (s *Service) summarizeWorkouts(workouts []workout.Workout, ftp int) []RecentWorkoutSummary {
summaries := make([]RecentWorkoutSummary, 0, len(workouts))
// Limit to last 10 workouts to keep prompt size manageable
count := 0
for _, w := range workouts {
if w.Status != "completed" {
continue
}
if count >= 10 {
break
}
count++
tss := 0.0
if ftp > 0 && w.AvgPower > 0 && w.Duration > 0 {
// TSS = (duration_seconds × (avgPower/FTP)²) / 36
intensityFactor := float64(w.AvgPower) / float64(ftp)
tss = (float64(w.Duration) * intensityFactor * intensityFactor) / 36.0
}
summaries = append(summaries, RecentWorkoutSummary{
Date: w.ScheduledDate.Format("2006-01-02"),
Type: w.Type,
Duration: w.Duration,
AvgPower: w.AvgPower,
TSS: tss,
})
}
return summaries
}
// calculateTrainingLoad computes CTL/ATL/TSB
func (s *Service) calculateTrainingLoad(userID uint, ftp int) TrainingLoadSummary {
summary := TrainingLoadSummary{CTL: 0, ATL: 0, TSB: 0}
if ftp == 0 {
return summary
}
// Get daily TSS for last 42 days (for CTL)
dailyTSS, err := s.statsRepo.GetDailyTSS(userID, ftp, 42)
if err != nil || len(dailyTSS) == 0 {
return summary
}
// Calculate CTL (42-day exponential moving average)
ctlTC := 42.0 // time constant
ctl := 0.0
for _, day := range dailyTSS {
ctl = ctl + (day.TSS-ctl)*(1.0/ctlTC)
}
// Calculate ATL (7-day exponential moving average, using last 7 days)
atlTC := 7.0
atl := 0.0
start := len(dailyTSS) - 7
if start < 0 {
start = 0
}
for i := start; i < len(dailyTSS); i++ {
atl = atl + (dailyTSS[i].TSS-atl)*(1.0/atlTC)
}
summary.CTL = math.Round(ctl*10) / 10
summary.ATL = math.Round(atl*10) / 10
summary.TSB = math.Round((ctl-atl)*10) / 10
return summary
}
// callDeepSeekAPI makes the HTTP request to DeepSeek
func (s *Service) callDeepSeekAPI(userCtx UserContext, req GenerateRequest) (string, error) {
apiKey := os.Getenv("DEEPSEEK_API_KEY")
if apiKey == "" {
return "", fmt.Errorf("DEEPSEEK_API_KEY not configured")
}
// Build prompts
systemPrompt := BuildSystemPrompt()
userPrompt := BuildUserPrompt(userCtx, req)
// Create request
deepseekReq := DeepSeekRequest{
Model: "deepseek-chat",
Messages: []DeepSeekMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
Temperature: 0.7,
MaxTokens: 8000, // Ensure enough tokens for complete response
}
reqBody, _ := json.Marshal(deepseekReq)
log.Printf("[AI] Calling DeepSeek API for user context with FTP=%d", userCtx.FTP)
// Make HTTP request
httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions", bytes.NewBuffer(reqBody))
if err != nil {
return "", err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
// Increase timeout to 180 seconds (3 minutes) for AI generation
client := &http.Client{Timeout: 180 * time.Second}
log.Printf("[AI] Sending request to DeepSeek API...")
resp, err := client.Do(httpReq)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("DeepSeek API error (status %d): %s", resp.StatusCode, string(body))
}
// Parse response
var deepseekResp DeepSeekResponse
if err := json.Unmarshal(body, &deepseekResp); err != nil {
return "", err
}
if deepseekResp.Error != nil {
return "", fmt.Errorf("DeepSeek API error: %s", deepseekResp.Error.Message)
}
if len(deepseekResp.Choices) == 0 {
return "", fmt.Errorf("no response from DeepSeek API")
}
content := deepseekResp.Choices[0].Message.Content
log.Printf("[AI] DeepSeek API call successful, response length: %d bytes", len(content))
// Log preview of response
if len(content) > 200 {
log.Printf("[AI] Response preview: %s...", content[:200])
} else {
log.Printf("[AI] Full response: %s", content)
}
return content, nil
}
// parseAIResponse extracts structured workout data from AI response
func (s *Service) parseAIResponse(aiResponse string) (*GenerateResponse, error) {
var response GenerateResponse
// Try to extract JSON from response (AI might wrap it in markdown)
cleaned := extractJSON(aiResponse)
log.Printf("[AI] Raw response length: %d bytes", len(aiResponse))
log.Printf("[AI] Cleaned response length: %d bytes", len(cleaned))
// Log first 500 chars for debugging
if len(cleaned) > 500 {
log.Printf("[AI] Response preview: %s...", cleaned[:500])
} else {
log.Printf("[AI] Full cleaned response: %s", cleaned)
}
if err := json.Unmarshal([]byte(cleaned), &response); err != nil {
log.Printf("[AI] Failed to parse JSON: %v", err)
log.Printf("[AI] Full raw response: %s", aiResponse)
return nil, fmt.Errorf("failed to parse JSON: %w (check logs for full response)", err)
}
return &response, nil
}
// ScheduleWorkouts converts AI recommendations to actual workouts
func (s *Service) ScheduleWorkouts(userID uint, recommendationID uint, workoutIndices []int) ([]*workout.Workout, error) {
// Get the recommendation
rec, err := s.aiRepo.GetRecommendation(recommendationID, userID)
if err != nil {
return nil, err
}
// Prevent duplicate scheduling
if rec.Status == "scheduled" {
return nil, fmt.Errorf("this training plan has already been scheduled")
}
// Parse generated workouts
var aiWorkouts []AIWorkout
if err := json.Unmarshal(rec.GeneratedWorkouts.Data, &aiWorkouts); err != nil {
return nil, err
}
// Schedule selected workouts
var scheduled []*workout.Workout
for _, idx := range workoutIndices {
if idx < 0 || idx >= len(aiWorkouts) {
continue
}
aiWorkout := aiWorkouts[idx]
scheduledDate, _ := time.Parse("2006-01-02", aiWorkout.ScheduledDate)
// Convert to workout model
workoutData := workout.WorkoutDataJSON{
Name: aiWorkout.Title,
TotalDuration: aiWorkout.Duration,
Segments: aiWorkout.Segments,
}
newWorkout := &workout.Workout{
UserID: userID,
Title: aiWorkout.Title,
Description: aiWorkout.Description,
Type: aiWorkout.Type,
Status: "planned",
ScheduledDate: scheduledDate,
Duration: aiWorkout.Duration,
WorkoutData: workoutData,
Notes: aiWorkout.Notes,
}
if err := s.workoutRepo.CreateWorkout(newWorkout); err != nil {
log.Printf("[AI] Failed to create workout: %v", err)
continue
}
scheduled = append(scheduled, newWorkout)
}
// Update recommendation status
if len(scheduled) > 0 {
s.aiRepo.UpdateRecommendationStatus(recommendationID, "scheduled")
}
return scheduled, nil
}
// GetUserRecommendations fetches recommendation history
func (s *Service) GetUserRecommendations(userID uint, limit int) ([]AIRecommendation, error) {
return s.aiRepo.GetUserRecommendations(userID, limit)
}
// fixWorkoutDurations corrects duration mismatches by recalculating from segments
func (s *Service) fixWorkoutDurations(workouts []AIWorkout) {
for i := range workouts {
totalDuration := 0
for _, seg := range workouts[i].Segments {
totalDuration += seg.Duration
}
// If there's a mismatch, fix the workout duration
if workouts[i].Duration != totalDuration {
log.Printf("[AI] Fixing workout %d duration: %d -> %d (sum of segments)",
i, workouts[i].Duration, totalDuration)
workouts[i].Duration = totalDuration
}
}
}
// GetUserFTP queries FTP from user_profiles table directly
func (s *Service) GetUserFTP(userID uint) (int, error) {
var ftp int
err := database.DB.Table("user_profiles").
Select("ftp").
Where("user_id = ?", userID).
Scan(&ftp).Error
return ftp, err
}

156
internal/ai/validator.go Normal file
View File

@@ -0,0 +1,156 @@
package ai
import (
"fmt"
"math"
"time"
)
// validateWorkouts ensures AI-generated workouts meet quality standards
func validateWorkouts(workouts []AIWorkout) error {
if len(workouts) == 0 {
return fmt.Errorf("no workouts generated")
}
for i, w := range workouts {
if err := validateWorkout(w, i); err != nil {
return err
}
}
return nil
}
func validateWorkout(w AIWorkout, index int) error {
// Required fields
if w.Title == "" {
return fmt.Errorf("workout %d: title is required", index)
}
if w.Duration <= 0 {
return fmt.Errorf("workout %d: invalid duration %d", index, w.Duration)
}
if w.Duration > 86400 {
return fmt.Errorf("workout %d: duration exceeds 24 hours", index)
}
// Validate date format
if _, err := time.Parse("2006-01-02", w.ScheduledDate); err != nil {
return fmt.Errorf("workout %d: invalid date format %s", index, w.ScheduledDate)
}
// Validate segments
if len(w.Segments) == 0 {
return fmt.Errorf("workout %d: must have at least one segment", index)
}
if len(w.Segments) > 100 {
return fmt.Errorf("workout %d: too many segments (%d), maximum 100", index, len(w.Segments))
}
totalDuration := 0
for j, seg := range w.Segments {
if seg.Duration <= 0 {
return fmt.Errorf("workout %d, segment %d: invalid duration %d", index, j, seg.Duration)
}
// Validate power values are reasonable (0-300% FTP)
if seg.Power < 0 || seg.Power > 3.0 {
return fmt.Errorf("workout %d, segment %d: power %.2f out of range (0-3.0)", index, j, seg.Power)
}
if seg.PowerLow < 0 || seg.PowerLow > 3.0 {
return fmt.Errorf("workout %d, segment %d: power_low %.2f out of range (0-3.0)", index, j, seg.PowerLow)
}
if seg.PowerHigh < 0 || seg.PowerHigh > 3.0 {
return fmt.Errorf("workout %d, segment %d: power_high %.2f out of range (0-3.0)", index, j, seg.PowerHigh)
}
// Validate that power_low <= power_high (except for ramps/cooldowns where power descends)
// For warmup, cooldown, and ramp types, power can go from high to low
if seg.PowerLow > 0 && seg.PowerHigh > 0 && seg.PowerLow > seg.PowerHigh {
// Allow descending power for warmup, cooldown, and ramp segments
if seg.Type != "warmup" && seg.Type != "cooldown" && seg.Type != "ramp" {
return fmt.Errorf("workout %d, segment %d: power_low (%.2f) > power_high (%.2f)", index, j, seg.PowerLow, seg.PowerHigh)
}
}
// Validate segment type
validTypes := map[string]bool{
"warmup": true,
"cooldown": true,
"steadystate": true,
"interval": true,
"ramp": true,
"rest": true,
"freeride": true,
}
if !validTypes[seg.Type] {
return fmt.Errorf("workout %d, segment %d: invalid type %s", index, j, seg.Type)
}
totalDuration += seg.Duration
}
// Verify total duration matches sum of segments (within 5% tolerance)
tolerance := float64(w.Duration) * 0.05
diff := math.Abs(float64(totalDuration - w.Duration))
if diff > tolerance {
return fmt.Errorf("workout %d: duration mismatch (declared %d, segments total %d)",
index, w.Duration, totalDuration)
}
// Validate TSS is reasonable (0-500)
if w.EstimatedTSS < 0 || w.EstimatedTSS > 500 {
return fmt.Errorf("workout %d: unrealistic TSS %.1f (expected 0-500)", index, w.EstimatedTSS)
}
return nil
}
// validateGenerateRequest validates user input
func validateGenerateRequest(req GenerateRequest) error {
if req.PlanDuration < 1 || req.PlanDuration > 28 {
return fmt.Errorf("plan_duration must be between 1 and 28 days")
}
if req.WeeklyHours < 1 || req.WeeklyHours > 40 {
return fmt.Errorf("weekly_hours must be between 1 and 40")
}
validIntensities := map[string]bool{"easy": true, "moderate": true, "hard": true}
if !validIntensities[req.IntensityLevel] {
return fmt.Errorf("intensity_level must be 'easy', 'moderate', or 'hard'")
}
if len(req.FocusAreas) == 0 {
return fmt.Errorf("at least one focus area is required")
}
if len(req.FocusAreas) > 3 {
return fmt.Errorf("maximum 3 focus areas allowed")
}
validFocusAreas := map[string]bool{
"endurance": true,
"threshold": true,
"vo2max": true,
"recovery": true,
"sprint": true,
"sweet_spot": true,
}
for _, focus := range req.FocusAreas {
if !validFocusAreas[focus] {
return fmt.Errorf("invalid focus area: %s", focus)
}
}
// Validate start date format
if _, err := time.Parse("2006-01-02", req.StartDate); err != nil {
return fmt.Errorf("invalid start_date format, use YYYY-MM-DD")
}
return nil
}