package ai import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "strings" "time" ) const openaiTimeout = 30 * time.Second type openaiRequest struct { Model string `json:"model"` Messages []openaiMessage `json:"messages"` Temperature float64 `json:"temperature"` } type openaiMessage struct { Role string `json:"role"` Content []openaiContent `json:"content"` } type openaiContent struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *openaiImage `json:"image_url,omitempty"` } type openaiImage struct { URL string `json:"url"` } type openaiResponse struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error,omitempty"` } type openaiProvider struct { apiKey string model string baseURL string } func newOpenAIProvider() openaiProvider { return openaiProvider{ apiKey: os.Getenv("OPENAI_API_KEY"), model: envOrDefault("AI_MODEL", "gpt-4o-mini"), baseURL: strings.TrimRight(envOrDefault("AI_BASE_URL", "https://api.openai.com/v1"), "/"), } } func (p openaiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) { if p.apiKey == "" { return &ReceiptData{}, errors.New("OPENAI_API_KEY environment variable not set") } imageData, err := readFile(imagePath) if err != nil { return &ReceiptData{}, fmt.Errorf("read file: %w", err) } mimeType := detectMimeType(imageData) if mimeType == "" { mimeType = "image/jpeg" } b64Data := base64.StdEncoding.EncodeToString(imageData) dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data) payload := openaiRequest{ Model: p.model, Temperature: 0.1, Messages: []openaiMessage{{ Role: "user", Content: []openaiContent{ {Type: "text", Text: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"product_name\" (string, product or item name), \"merchant\" (string, store name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, one of: Electronics, Furniture, Appliances, Clothing, Home, Other), \"date\" (string, YYYY-MM-DD), \"warranty_months\" (number, 0 if not visible), \"return_days\" (number, 0 if not visible). Return ONLY valid JSON. No markdown, no explanation, no code fences."}, {Type: "image_url", ImageURL: &openaiImage{URL: dataURL}}, }, }}, } body, err := json.Marshal(payload) if err != nil { return &ReceiptData{}, fmt.Errorf("marshal request: %w", err) } apiURL := p.baseURL + "/chat/completions" req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(body)) if err != nil { return &ReceiptData{}, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.apiKey) client := &http.Client{Timeout: openaiTimeout} resp, err := client.Do(req) if err != nil { return &ReceiptData{}, fmt.Errorf("API request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return &ReceiptData{}, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return &ReceiptData{}, fmt.Errorf("API status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) } var apiResp openaiResponse if err := json.Unmarshal(respBody, &apiResp); err != nil { return &ReceiptData{}, fmt.Errorf("parse response: %w", err) } if apiResp.Error != nil { return &ReceiptData{}, fmt.Errorf("API error: %s", apiResp.Error.Message) } if len(apiResp.Choices) == 0 { return &ReceiptData{}, errors.New("no response choices from API") } contentStr := stripMarkdownFences(apiResp.Choices[0].Message.Content) var receipt ReceiptData if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil { return &ReceiptData{}, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr) } log.Printf("ExtractReceipt [openai-%s]: merchant=%q amount=%.2f %s", p.model, receipt.Merchant, receipt.Amount, receipt.Currency) return &receipt, nil }