package ai import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "time" ) // ReceiptData represents the structured data extracted from a receipt image. type ReceiptData struct { Amount float64 `json:"amount"` Currency string `json:"currency"` Merchant string `json:"merchant"` Category string `json:"category"` Date string `json:"date"` } const ( deepseekAPIURL = "https://api.deepseek.com/v1/chat/completions" deepseekModel = "deepseek-vl2" requestTimeout = 30 * time.Second ) // deepseekRequest matches the DeepSeek Vision API request format (OpenAI-compatible). type deepseekRequest struct { Model string `json:"model"` Messages []deepseekMessage `json:"messages"` Temperature float64 `json:"temperature"` } type deepseekMessage struct { Role string `json:"role"` Content []deepseekContent `json:"content"` } type deepseekContent struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *imageURLValue `json:"image_url,omitempty"` } type imageURLValue struct { URL string `json:"url"` } // deepseekResponse matches the DeepSeek API response (OpenAI-compatible). type deepseekResponse struct { Choices []deepseekChoice `json:"choices"` } type deepseekChoice struct { Message deepseekResponseMessage `json:"message"` } type deepseekResponseMessage struct { Content string `json:"content"` } // ExtractReceipt sends a receipt image to the DeepSeek Vision API and parses // the structured receipt data from the response. Returns default (empty) data // along with an error if any step of the process fails. func ExtractReceipt(imagePath string) (*ReceiptData, error) { // 1. Validate the image file exists and is readable. imageData, err := readImageFile(imagePath) if err != nil { log.Printf("ExtractReceipt: failed to read image file %q: %v", imagePath, err) return &ReceiptData{}, err } // 2. Get the API key from the environment. apiKey := os.Getenv("DEEPSEEK_API_KEY") if apiKey == "" { err := errors.New("DEEPSEEK_API_KEY environment variable is not set") log.Printf("ExtractReceipt: %v", err) return &ReceiptData{}, err } // 3. Build the request payload. base64Image := base64.StdEncoding.EncodeToString(imageData) payload := deepseekRequest{ Model: deepseekModel, Temperature: 0.2, Messages: []deepseekMessage{ { Role: "user", Content: []deepseekContent{ { Type: "text", Text: "Analyze this receipt image. Extract the following fields as a strict JSON object: amount (float), currency (3-letter string), merchant (string), category (one of: Food, Travel, Lodging, Software, Other), date (YYYY-MM-DD). Do not return markdown, only raw JSON.", }, { Type: "image_url", ImageURL: &imageURLValue{ URL: fmt.Sprintf("data:image/jpeg;base64,%s", base64Image), }, }, }, }, }, } body, err := json.Marshal(payload) if err != nil { log.Printf("ExtractReceipt: failed to marshal request payload: %v", err) return &ReceiptData{}, err } // 4. Send the POST request. req, err := http.NewRequest(http.MethodPost, deepseekAPIURL, bytes.NewReader(body)) if err != nil { log.Printf("ExtractReceipt: failed to create HTTP request: %v", err) return &ReceiptData{}, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) client := &http.Client{Timeout: requestTimeout} resp, err := client.Do(req) if err != nil { log.Printf("ExtractReceipt: API request failed: %v", err) return &ReceiptData{}, err } defer resp.Body.Close() // 5. Read the response body. respBody, err := io.ReadAll(resp.Body) if err != nil { log.Printf("ExtractReceipt: failed to read response body: %v", err) return &ReceiptData{}, err } if resp.StatusCode != http.StatusOK { err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) log.Printf("ExtractReceipt: %v", err) return &ReceiptData{}, err } // 6. Parse the DeepSeek response (OpenAI-compatible format). var apiResp deepseekResponse if err := json.Unmarshal(respBody, &apiResp); err != nil { log.Printf("ExtractReceipt: failed to parse API response: %v", err) return &ReceiptData{}, err } if len(apiResp.Choices) == 0 { err := errors.New("API response contains no choices") log.Printf("ExtractReceipt: %v", err) return &ReceiptData{}, err } contentStr := apiResp.Choices[0].Message.Content // 7. Parse the nested JSON from the content field into ReceiptData. var receipt ReceiptData if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil { log.Printf("ExtractReceipt: failed to parse receipt JSON from content: %v", err) return &ReceiptData{}, err } return &receipt, nil } // readImageFile reads the full contents of an image file after verifying it // exists and is a regular file. func readImageFile(path string) ([]byte, error) { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("image file does not exist: %s", path) } return nil, fmt.Errorf("cannot stat image file %s: %w", path, err) } if info.IsDir() { return nil, fmt.Errorf("path is a directory, not an image file: %s", path) } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("cannot read image file %s: %w", path, err) } return data, nil }