This commit is contained in:
2025-09-17 17:30:18 -07:00
parent 6bad6cae00
commit 84ba6432c2
65 changed files with 434 additions and 765 deletions

133
internal/analysis/client.go Normal file
View File

@@ -0,0 +1,133 @@
package analysis
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/sstent/fitness-tui/internal/circuitbreaker"
"github.com/go-resty/resty/v2"
"github.com/sstent/fitness-tui/internal/config"
)
// OpenRouterClient handles communication with the OpenRouter API
// It manages request retries, circuit breaking, and authentication
type OpenRouterClient struct {
client *resty.Client // HTTP client instance
config *config.Config // Application configuration
circuitBreaker *circuitbreaker.CircuitBreaker // Circuit breaker for API availability
}
func NewOpenRouterClient(cfg *config.Config) *OpenRouterClient {
timeout := cfg.OpenRouter.Timeout
if timeout == 0 {
// Fallback to 30s if timeout is not set
timeout = 30 * time.Second
}
cb := circuitbreaker.New(5, 30*time.Second)
return &OpenRouterClient{
client: resty.New().
SetBaseURL(cfg.OpenRouter.BaseURL).
SetTimeout(timeout).
SetHeader("Content-Type", "application/json").
SetHeader("HTTP-Referer", "https://github.com/sstent/fitness-tui").
SetHeader("Authorization", fmt.Sprintf("Bearer %s", cfg.OpenRouter.APIKey)),
config: cfg,
circuitBreaker: cb,
}
}
type AnalysisResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func (c *OpenRouterClient) AnalyzeActivity(ctx context.Context, params PromptParams) (string, error) {
// Generate prompt using the prompts package
var err error
prompt := GeneratePrompt(params)
payload := map[string]interface{}{
"model": c.config.OpenRouter.Model,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
var response AnalysisResponse
var resp *resty.Response
// Check circuit breaker state
if !c.circuitBreaker.AllowRequest() {
return "", fmt.Errorf("API unavailable (circuit breaker open)")
}
// Retry with exponential backoff and jitter
maxRetries := 5
baseDelay := 500 * time.Millisecond
var lastErr error
for i := 0; i < maxRetries; i++ {
// Check if we should abort due to circuit breaker
if !c.circuitBreaker.AllowRequest() {
return "", fmt.Errorf("API unavailable (circuit breaker open)")
}
resp, err = c.client.R().
SetContext(ctx).
SetBody(payload).
SetResult(&response).
Post("/chat/completions")
if err == nil && resp.IsSuccess() {
break
}
// Handle rate limiting (429)
if resp != nil && resp.StatusCode() == 429 {
retryAfter := resp.Header().Get("Retry-After")
if retryAfter != "" {
if delay, err := time.ParseDuration(retryAfter + "s"); err == nil {
time.Sleep(delay)
continue
}
}
}
// If context cancelled, break immediately
if ctx.Err() != nil {
return "", ctx.Err()
}
// Calculate next backoff with jitter
delay := baseDelay * time.Duration(1<<uint(i)) // Exponential: 500ms, 1s, 2s, 4s, 8s
jitter := time.Duration(rand.Int63n(int64(delay / 2))) // Up to 50% jitter
totalDelay := delay + jitter
// Store last error for final return
if err != nil {
lastErr = fmt.Errorf("attempt %d: %w", i+1, err)
} else {
lastErr = fmt.Errorf("attempt %d: API error %s", i+1, resp.Status())
}
time.Sleep(totalDelay)
}
if err != nil || resp.IsError() {
c.circuitBreaker.RecordFailure()
return "", fmt.Errorf("API request failed: %w", lastErr)
}
c.circuitBreaker.RecordSuccess()
if len(response.Choices) == 0 || response.Choices[0].Message.Content == "" {
return "", fmt.Errorf("empty analysis content in API response")
}
return response.Choices[0].Message.Content, nil
}

View File

@@ -0,0 +1,95 @@
package analysis
import (
"time"
)
// DownsampledPoint represents a single data point in a downsampled metric series
type DownsampledPoint struct {
TimeOffset int `json:"time_offset"` // Seconds from activity start
Value float64 `json:"value"` // Average value during this segment
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
}
// DownsampleMetric downsamples a metric array to the specified number of points
func DownsampleMetric(data []float64, duration time.Duration, targetPoints int) []DownsampledPoint {
if len(data) == 0 || targetPoints <= 0 {
return nil
}
totalSeconds := int(duration.Seconds())
if totalSeconds <= 0 {
return nil
}
// Calculate segment duration in seconds (floating point for accuracy)
segmentDuration := float64(totalSeconds) / float64(targetPoints)
if segmentDuration < 1 {
segmentDuration = 1
}
// Preallocate segments array
segments := make([]struct {
sum float64
count int
min float64
max float64
}, targetPoints)
// Initialize min/max with zero values - we'll update them when we see data
// Assign data points to segments
for i, value := range data {
// Calculate time offset for this data point
timeOffset := float64(i) * float64(totalSeconds) / float64(len(data))
segmentIndex := int(timeOffset / segmentDuration)
// Clamp to valid segment range
if segmentIndex >= targetPoints {
segmentIndex = targetPoints - 1
}
seg := &segments[segmentIndex]
seg.sum += value
seg.count++
// Initialize min/max on first value in segment
if seg.count == 1 {
seg.min = value
seg.max = value
} else {
if value < seg.min {
seg.min = value
}
if value > seg.max {
seg.max = value
}
}
}
// Build results array
results := make([]DownsampledPoint, targetPoints)
for j := 0; j < targetPoints; j++ {
seg := &segments[j]
timeOffset := int(float64(j) * segmentDuration)
if seg.count == 0 {
// For empty segments, omit min/max
results[j] = DownsampledPoint{
TimeOffset: timeOffset,
Value: 0,
}
} else {
avg := seg.sum / float64(seg.count)
results[j] = DownsampledPoint{
TimeOffset: timeOffset,
Value: avg,
Min: seg.min,
Max: seg.max,
}
}
}
return results
}

View File

@@ -0,0 +1,41 @@
package analysis
import (
"encoding/json"
"fmt"
"strings"
"github.com/sstent/fitness-tui/internal/config"
"github.com/sstent/fitness-tui/internal/tui/models"
)
type PromptParams struct {
Activity *models.Activity
Goal string
Locale string
TrainingContext interface{} `json:"training_context,omitempty"`
Config *config.Config
}
func GeneratePrompt(params PromptParams) string {
var prompt strings.Builder
prompt.WriteString(fmt.Sprintf("Analyze this %s workout from %s:\n",
params.Activity.Type, params.Activity.Date.Format("2006-01-02")))
prompt.WriteString(fmt.Sprintf("- Duration: %s\n", params.Activity.Duration))
prompt.WriteString(fmt.Sprintf("- Distance: %.1f km\n", params.Activity.Distance/1000))
prompt.WriteString(fmt.Sprintf("- Elevation: %.0f m\n", params.Activity.Metrics.ElevationGain))
prompt.WriteString(fmt.Sprintf("- Avg Power: %.0fW\n", params.Activity.Metrics.AvgPower))
prompt.WriteString(fmt.Sprintf("- Avg HR: %d bpm\n", params.Activity.Metrics.AvgHeartRate))
prompt.WriteString("\nTraining Context:\n")
if params.TrainingContext != nil {
contextJSON, _ := json.Marshal(params.TrainingContext)
prompt.WriteString(string(contextJSON))
}
prompt.WriteString("\n\nProvide structured analysis in this format:\n")
prompt.WriteString("- Summary: [concise overview]\n")
prompt.WriteString("- Strengths: [2-3 bullet points]\n")
prompt.WriteString("- Improvements: [2-3 actionable suggestions]")
return prompt.String()
}

View File

@@ -0,0 +1,77 @@
package prompts
import (
"fmt"
"strings"
"text/template"
"github.com/sstent/fitness-tui/internal/tui/models"
)
// PromptTemplate defines a structured prompt for LLM analysis
type PromptTemplate struct {
System string
User string
Functions []string
}
// GetAnalysisPrompt returns the prompt template for activity analysis
func GetAnalysisPrompt(activity *models.Activity) PromptTemplate {
return PromptTemplate{
System: "You are an experienced cycling coach analyzing a training session. Provide concise, actionable feedback focusing on training execution, intensity control, and areas for improvement. Use markdown formatting for headings and lists. Structure your response with the following sections: Workout Execution, Intensity Control, Areas for Improvement, and Recommendations.",
User: fmt.Sprintf(`## Activity Overview
- **Name**: %s
- **Type**: %s (%s)
- **Date**: %s
- **Duration**: %s
- **Distance**: %.1f km
- **Elevation Gain**: %.0f m
## Key Metrics
- **Avg Heart Rate**: %d bpm
- **Avg Power**: %.0f w
- **TSS**: %.0f
- **Intensity Factor (IF)**: %.1f
- **Normalized Power (NP)**: %.0f w
## Training Context
- **Training Goal**: %s
## Analysis Request
Please provide a detailed analysis of this activity in relation to the athlete's training goal. Focus on:
1. Workout execution: Did the athlete achieve the intended goal?
2. Intensity control: How well did the athlete manage their effort?
3. Areas for improvement: What could be done better in future similar workouts?
4. Recommendations: Any adjustments for upcoming training?`,
activity.Name,
activity.Type,
activity.ActivityType,
activity.Date.Format("2006-01-02 15:04"),
activity.Duration,
activity.Distance/1000, // Convert meters to km
activity.Metrics.ElevationGain,
activity.Metrics.AvgHeartRate,
activity.Metrics.AvgPower,
activity.Metrics.TrainingStressScore,
activity.Metrics.IntensityFactor,
activity.Metrics.NormalizedPower,
activity.Metrics.TargetZones,
),
Functions: []string{},
}
}
// RenderTemplate renders the prompt template with activity data
func RenderTemplate(tmpl string, data interface{}) (string, error) {
t, err := template.New("prompt").Parse(tmpl)
if err != nil {
return "", err
}
var buf strings.Builder
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@@ -0,0 +1,89 @@
package analysis
import (
"context"
"fmt"
"time"
"github.com/sstent/fitness-tui/internal/storage"
"github.com/sstent/fitness-tui/internal/tui/models"
)
type ServiceResponse struct {
Analysis string
Error error
Duration time.Duration
}
type AnalysisService struct {
client *OpenRouterClient
storage *storage.AnalysisCache
}
func NewAnalysisService(client *OpenRouterClient, storage *storage.AnalysisCache) *AnalysisService {
return &AnalysisService{
client: client,
storage: storage,
}
}
// GetAnalysis retrieves cached analysis or generates new analysis if needed
func (s *AnalysisService) GetAnalysis(ctx context.Context, activity *models.Activity, workoutGoal string) (string, error) {
// Check cache first
if content, _, err := s.storage.GetAnalysis(activity.ID); err == nil {
return content, nil
}
// Build prompt
trainingContext := map[string]interface{}{
"FTP": activity.Metrics.FTP,
"WorkoutType": workoutGoal,
"TargetZones": activity.Metrics.TargetZones,
"TrainingLoad": activity.Metrics.TrainingLoad,
"ElevationProfile": activity.Metrics.ElevationProfile,
"FatigueLevel": activity.Metrics.FatigueLevel,
}
promptParams := PromptParams{
Activity: activity,
TrainingContext: trainingContext,
Config: s.client.config,
}
// Generate analysis
analysis, err := s.client.AnalyzeActivity(ctx, promptParams)
if err != nil {
return "", fmt.Errorf("failed to generate analysis: %w", err)
}
// Cache the result with metadata
meta := storage.AnalysisMetadata{
ActivityID: activity.ID,
GeneratedAt: time.Now(),
ModelUsed: s.client.config.OpenRouter.Model,
}
if err := s.storage.StoreAnalysis(activity, analysis, meta); err != nil {
return "", fmt.Errorf("failed to cache analysis: %w", err)
}
return analysis, nil
}
// GenerateAnalysisAsync starts analysis in a goroutine and returns a channel for the result
func (s *AnalysisService) GenerateAnalysisAsync(ctx context.Context, activity *models.Activity, workoutGoal string) <-chan ServiceResponse {
resultChan := make(chan ServiceResponse, 1)
go func() {
start := time.Now()
analysis, err := s.GetAnalysis(ctx, activity, workoutGoal)
duration := time.Since(start)
resultChan <- ServiceResponse{
Analysis: analysis,
Error: err,
Duration: duration,
}
}()
return resultChan
}