apps/cellar/claude.go 3.2 K raw
1
package main
2
3
import (
4
	"bytes"
5
	"context"
6
	"encoding/base64"
7
	"encoding/json"
8
	"fmt"
9
	"io"
10
	"net/http"
11
	"strings"
12
	"time"
13
)
14
15
type AnalyzeResult struct {
16
	Name       string `json:"name"`
17
	Origin     string `json:"origin"`
18
	Grape      string `json:"grape"`
19
	Background string `json:"background"`
20
}
21
22
type claudeImageSource struct {
23
	Type      string `json:"type"`
24
	MediaType string `json:"media_type"`
25
	Data      string `json:"data"`
26
}
27
28
type claudeContent struct {
29
	Type   string             `json:"type"`
30
	Source *claudeImageSource `json:"source,omitempty"`
31
	Text   string             `json:"text,omitempty"`
32
}
33
34
type claudeMessage struct {
35
	Role    string          `json:"role"`
36
	Content []claudeContent `json:"content"`
37
}
38
39
type claudeRequest struct {
40
	Model     string          `json:"model"`
41
	MaxTokens int             `json:"max_tokens"`
42
	Messages  []claudeMessage `json:"messages"`
43
}
44
45
type claudeResponse struct {
46
	Content []struct {
47
		Text string `json:"text"`
48
	} `json:"content"`
49
}
50
51
const claudeModel = "claude-sonnet-4-20250514"
52
const claudePrompt = `Look at this wine bottle label. Return a JSON object with exactly these fields: {"name": "the full wine name", "origin": "region and/or country", "grape": "grape variety or blend", "background": "brief background about the wine and the winery, including any notable history or interesting facts"}. If you cannot determine a field, use an empty string. Respond with ONLY the JSON, no other text.`
53
54
func analyzeWineImage(ctx context.Context, apiKey string, imageBytes []byte, mediaType string) (*AnalyzeResult, error) {
55
	encoded := base64.StdEncoding.EncodeToString(imageBytes)
56
	req := claudeRequest{
57
		Model:     claudeModel,
58
		MaxTokens: 1024,
59
		Messages: []claudeMessage{{
60
			Role: "user",
61
			Content: []claudeContent{
62
				{Type: "image", Source: &claudeImageSource{Type: "base64", MediaType: mediaType, Data: encoded}},
63
				{Type: "text", Text: claudePrompt},
64
			},
65
		}},
66
	}
67
	body, err := json.Marshal(req)
68
	if err != nil {
69
		return nil, err
70
	}
71
	client := &http.Client{Timeout: 60 * time.Second}
72
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(body))
73
	if err != nil {
74
		return nil, err
75
	}
76
	httpReq.Header.Set("x-api-key", apiKey)
77
	httpReq.Header.Set("anthropic-version", "2023-06-01")
78
	httpReq.Header.Set("content-type", "application/json")
79
	resp, err := client.Do(httpReq)
80
	if err != nil {
81
		return nil, fmt.Errorf("Request failed: %w", err)
82
	}
83
	defer resp.Body.Close()
84
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
85
		buf, _ := io.ReadAll(resp.Body)
86
		return nil, fmt.Errorf("API error %s: %s", resp.Status, string(buf))
87
	}
88
	var cr claudeResponse
89
	if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
90
		return nil, fmt.Errorf("Failed to parse response: %w", err)
91
	}
92
	var text string
93
	for _, c := range cr.Content {
94
		if c.Text != "" {
95
			text = c.Text
96
			break
97
		}
98
	}
99
	if text == "" {
100
		return nil, fmt.Errorf("No text in response")
101
	}
102
	text = strings.TrimSpace(text)
103
	if i := strings.Index(text, "{"); i >= 0 {
104
		if j := strings.LastIndex(text, "}"); j > i {
105
			text = text[i : j+1]
106
		}
107
	}
108
	var result AnalyzeResult
109
	if err := json.Unmarshal([]byte(text), &result); err != nil {
110
		return nil, fmt.Errorf("Failed to parse JSON: %w", err)
111
	}
112
	return &result, nil
113
}