package handlers import ( "context" "encoding/base64" "encoding/json" "io" "net/http" "strings" "github.com/rwcarlsen/goexif/exif" "adam-french.co.uk/backend/models" "github.com/anthropics/anthropic-sdk-go" "github.com/gin-gonic/gin" ) type ExtractedRowingData struct { TimeMinutes uint64 `json:"timeMinutes"` TimeSeconds uint64 `json:"timeSeconds"` Distance uint64 `json:"distance"` } func (store *Store) GetRowing(ctx *gin.Context) { var rowing []models.Rowing if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil { ctx.JSON(http.StatusInternalServerError, err.Error()) return } ctx.JSON(http.StatusOK, rowing) } func (store *Store) CreateRowing(ctx *gin.Context) { file, err := ctx.FormFile("image") if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "image is required"}) return } f, err := file.Open() if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open image"}) return } defer f.Close() // Get the date taken from the EXIF data x, err := exif.Decode(f) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "no EXIF data found"}) return } dateTaken, err := x.DateTime() if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "no date found in EXIF data"}) return } // Seek back to start since exif.Decode advanced the cursor if _, err := f.Seek(0, io.SeekStart); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek image"}) return } data, err := io.ReadAll(f) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read image"}) return } allowedMediaTypes := map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true, } mediaType := file.Header.Get("Content-Type") if !allowedMediaTypes[mediaType] { ctx.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image type"}) return } encoded := base64.StdEncoding.EncodeToString(data) // Reject duplicates: same EXIF datetime already recorded var existing models.Rowing if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil { ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"}) return } // Build the message with an image + text prompt message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{ Model: anthropic.ModelClaudeHaiku4_5, MaxTokens: 256, Messages: []anthropic.MessageParam{ { Role: "user", Content: []anthropic.ContentBlockParamUnion{ // Image block anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{ Type: "base64", MediaType: anthropic.Base64ImageSourceMediaType(mediaType), Data: encoded, }), // Text prompt requesting exactly 2 variables anthropic.NewTextBlock( `Look at this rowing machine display. Extract the total elapsed time and total distance. Return ONLY a JSON object with these exact keys and numeric values: - "timeMinutes": total minutes (e.g. 2:30 = 2) - "timeSeconds": total seconds (e.g. 2:30 = 30) - "distance": distance in meters as a number (e.g. 5000) No text, no markdown, no explanation. Just the JSON object.`), }, }, }, }) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ "raw": message.Content[0].Text, "error": err.Error(), }) return } if len(message.Content) == 0 { ctx.JSON(http.StatusInternalServerError, gin.H{ "raw": message.Content[0].Text, "error": "empty response from Claude", }) return } extractedData := ExtractedRowingData{} raw := message.Content[0].Text raw = strings.TrimSpace(raw) raw = strings.TrimPrefix(raw, "```json") raw = strings.TrimPrefix(raw, "```") raw = strings.TrimSuffix(raw, "```") raw = strings.TrimSpace(raw) err = json.Unmarshal([]byte(raw), &extractedData) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ "error": "failed to parse JSON response", "detail": err.Error(), "raw": raw, }) return } if extractedData.Distance == 0 { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid distance in image"}) return } totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds // Validate for anomalous values const ( minDistance = 100 // metres maxDistance = 100000 // metres minTotalSecs = 30 // 30 seconds maxTotalSecs = 7200 // 2 hours minPacePer500m = 80 // ~1:20 /500m (faster than any human) maxPacePer500m = 150 // ~2:30 /500m (slow, not important) ) if extractedData.Distance < minDistance || extractedData.Distance > maxDistance { ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"}) return } if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs { ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"}) return } per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0 if per500m < minPacePer500m || per500m > maxPacePer500m { ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"}) return } calories := float64(extractedData.Distance) / 7500.0 * 500.0 rowing := models.Rowing{ Date: dateTaken, Time: totalSeconds, TimePer500m: per500m, Distance: extractedData.Distance, Calories: calories, } if err := store.DB.Create(&rowing).Error; err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save rowing"}) return } ctx.JSON(http.StatusCreated, rowing) }