caltrack/ai/ai.go
Bastian Gruber abf2a3f05a
Some checks failed
Build and Push / build (push) Has been cancelled
fix: change model, add CLAUDE file
2026-02-24 08:22:18 -04:00

123 lines
3.6 KiB
Go

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-sonnet-4-6",
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
}