mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-02-16 04:06:34 +00:00
sync
This commit is contained in:
133
internal/analysis/client.go
Normal file
133
internal/analysis/client.go
Normal 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
|
||||
}
|
||||
95
internal/analysis/downsample.go
Normal file
95
internal/analysis/downsample.go
Normal 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
|
||||
}
|
||||
41
internal/analysis/prompts.go
Normal file
41
internal/analysis/prompts.go
Normal 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()
|
||||
}
|
||||
77
internal/analysis/prompts/prompts.go
Normal file
77
internal/analysis/prompts/prompts.go
Normal 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
|
||||
}
|
||||
89
internal/analysis/service.go
Normal file
89
internal/analysis/service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user