package ai import ( "caltrack/db" "caltrack/types" "context" "encoding/json" "fmt" "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" ) type Client struct { cfg *types.Config db *db.DB } func New(cfg *types.Config, database *db.DB) *Client { return &Client{ cfg: cfg, db: database, } } const systemPrompt = `You are a nutrition and exercise expert. Your job is to estimate calories for food items and exercises. When given a food description, respond with a JSON object containing an "items" array. Each item should have: - "name": the food item name (e.g., "Banana") - "brand": brand name if applicable, empty string otherwise - "kcal": estimated calories (integer) - "serving_size": the amount (e.g., 1, 200) - "serving_unit": unit (e.g., "piece", "g", "ml") When given an exercise description, respond with a JSON object containing an "items" array with a single item: - "name": the exercise name - "brand": empty string - "kcal": estimated calories burned (integer, positive number) - "serving_size": duration or distance - "serving_unit": "min" or "km" etc. For branded products (like "Snickers bar", "Coca Cola 330ml", "Lidl protein pudding"), use real nutritional data. IMPORTANT: Respond ONLY with valid JSON. No markdown, no explanation, just the JSON object. Examples: Input: "ate a banana and a protein shake" Output: {"items":[{"name":"Banana","brand":"","kcal":105,"serving_size":1,"serving_unit":"piece"},{"name":"Protein shake","brand":"","kcal":250,"serving_size":1,"serving_unit":"serving"}]} Input: "ran 5k in 25 minutes" Output: {"items":[{"name":"Running 5K","brand":"","kcal":350,"serving_size":5,"serving_unit":"km"}]} Input: "Snickers bar" Output: {"items":[{"name":"Snickers Bar","brand":"Mars","kcal":250,"serving_size":52,"serving_unit":"g"}]}` func (c *Client) EstimateCalories(ctx context.Context, description string, entryType types.EntryType) (*types.AIEstimation, error) { if c.cfg.AnthropicAPIKey == "" { return nil, fmt.Errorf("Anthropic API key not configured - go to settings to add it") } client := anthropic.NewClient( option.WithAPIKey(c.cfg.AnthropicAPIKey), ) prompt := fmt.Sprintf("Estimate calories for this %s: %s", entryType, description) msg, err := client.Messages.New(ctx, anthropic.MessageNewParams{ Model: "claude-haiku-4-5-20251001", MaxTokens: 1024, System: []anthropic.TextBlockParam{ {Text: systemPrompt}, }, Messages: []anthropic.MessageParam{ anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)), }, }) if err != nil { return nil, fmt.Errorf("anthropic API error: %w", err) } if len(msg.Content) == 0 { return nil, fmt.Errorf("empty response from API") } text := msg.Content[0].Text // Strip markdown code fences if present text = strings.TrimSpace(text) if strings.HasPrefix(text, "```") { if i := strings.Index(text, "\n"); i != -1 { text = text[i+1:] } if i := strings.LastIndex(text, "```"); i != -1 { text = text[:i] } text = strings.TrimSpace(text) } var estimation types.AIEstimation if err := json.Unmarshal([]byte(text), &estimation); err != nil { return nil, fmt.Errorf("failed to parse AI response: %w (response: %s)", err, text) } // Cache products for _, item := range estimation.Items { if entryType == types.EntryFood { existing, _ := c.db.FindProduct(item.Name, item.Brand) if existing == nil { c.db.SaveProduct(types.Product{ Name: item.Name, Brand: item.Brand, ServingSize: item.ServingSize, ServingUnit: item.ServingUnit, KcalPerServing: item.Kcal, Source: "ai", }) } } } return &estimation, nil }