- 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
191 lines
5.3 KiB
Go
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
|
|
}
|