124 lines
3.6 KiB
Go
124 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-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
|
||
|
|
}
|