lots of stuff, don't truly remember
This commit is contained in:
133
internal/ai/handler.go
Normal file
133
internal/ai/handler.go
Normal 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
170
internal/ai/model.go
Normal 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
214
internal/ai/prompt.go
Normal 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
66
internal/ai/repository.go
Normal 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
478
internal/ai/service.go
Normal 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
156
internal/ai/validator.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user