| 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 | } |