inboxer/internal/ai/deepseek.go
cclohmar ca970104ee chore: initial commit — ExpenseFlow AI-Powered Expense Tracker
- Passwordless email OTP authentication
- Event-based expense tracking with HTMX UI
- AI receipt extraction via DeepSeek Vision API
- CSV/PDF report generation with email filing
- PWA with service worker and manifest
- Mobile-first responsive design
- SQLite database with auto-migration
2026-05-29 19:43:30 +00:00

191 lines
5.3 KiB
Go

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
}