feat: switch AI provider from DeepSeek to Google Gemini Vision API

- DeepSeek API does not support vision (only chat UI supports images)
- Google Gemini Vision supports native image analysis via inline_data
- Images are sent directly as base64 with proper MIME type detection
- No more OCR pipeline needed - Gemini sees the image directly
- Supports: JPEG, PNG, WebP, GIF, BMP, TIFF, HEIC, AVIF
- Updated .env.example to use GEMINI_API_KEY instead of DEEPSEEK_API_KEY
- Also: log OTP code in server log for easier debugging
This commit is contained in:
Claus Lohmar 2026-05-30 12:11:20 +00:00
parent fecf4af8a2
commit 127046c9b7
2 changed files with 160 additions and 168 deletions

View file

@ -2,16 +2,14 @@ package ai
import ( import (
"bytes" "bytes"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"image"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
) )
@ -26,231 +24,203 @@ type ReceiptData struct {
} }
const ( const (
deepseekAPIURL = "https://api.deepseek.com/v1/chat/completions" geminiAPIURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent"
deepseekModel = "deepseek-v4-flash" requestTimeout = 30 * time.Second
requestTimeout = 60 * time.Second
) )
// deepseekRequest matches the DeepSeek API request format. // --- Gemini API types ---
type deepseekRequest struct {
Model string `json:"model"` type geminiRequest struct {
Messages []deepseekMessage `json:"messages"` Contents []geminiContent `json:"contents"`
Temperature float64 `json:"temperature"`
} }
type deepseekMessage struct { type geminiContent struct {
Role string `json:"role"` Parts []geminiPart `json:"parts"`
Content string `json:"content"`
} }
// deepseekResponse matches the DeepSeek API response format. type geminiPart struct {
type deepseekResponse struct { Text string `json:"text,omitempty"`
Choices []deepseekChoice `json:"choices"` InlineData *geminiFileData `json:"inline_data,omitempty"`
} }
type deepseekChoice struct { type geminiFileData struct {
Message deepseekResponseMessage `json:"message"` MimeType string `json:"mime_type"`
Data string `json:"data"`
} }
type deepseekResponseMessage struct { type geminiResponse struct {
Content string `json:"content"` Candidates []geminiCandidate `json:"candidates"`
Error *geminiError `json:"error,omitempty"`
} }
// ExtractReceipt extracts structured receipt data from an image using a two-step type geminiCandidate struct {
// pipeline: OCR (Tesseract) → LLM parsing (DeepSeek). Content geminiResponseContent `json:"content"`
}
type geminiResponseContent struct {
Parts []geminiResponsePart `json:"parts"`
}
type geminiResponsePart struct {
Text string `json:"text"`
}
type geminiError struct {
Message string `json:"message"`
}
// ExtractReceipt sends a receipt image to Google Gemini Vision API and
// returns structured receipt data extracted from the image.
// //
// Step 1 — OCR: Run Tesseract on the receipt image to extract raw text. // Gemini supports native vision processing via inline_data, so the image
// Step 2 — LLM: Send the OCR text to DeepSeek to parse into structured JSON. // is sent directly as base64-encoded data with its MIME type.
//
// This works with any image format (JPEG, PNG, HEIC, etc.) and produces
// real extracted data rather than AI-hallucinated values.
func ExtractReceipt(imagePath string) (*ReceiptData, error) { func ExtractReceipt(imagePath string) (*ReceiptData, error) {
apiKey := os.Getenv("DEEPSEEK_API_KEY") // 1. Read the image file.
if apiKey == "" { imageData, err := readImageFile(imagePath)
return &ReceiptData{}, errors.New("DEEPSEEK_API_KEY environment variable is not set")
}
// Step 1: OCR — extract text from the receipt image.
ocrText, err := ocrImage(imagePath)
if err != nil { if err != nil {
log.Printf("ExtractReceipt: OCR failed: %v", err)
return &ReceiptData{}, fmt.Errorf("OCR failed: %w", err)
}
ocrText = strings.TrimSpace(ocrText)
if ocrText == "" {
return &ReceiptData{}, errors.New("OCR returned no text from the receipt image")
}
log.Printf("ExtractReceipt: OCR extracted %d characters of text", len(ocrText))
// Step 2: LLM — send OCR text to DeepSeek for structured parsing.
receipt, err := parseReceiptText(ocrText, apiKey)
if err != nil {
log.Printf("ExtractReceipt: LLM parsing failed: %v", err)
return &ReceiptData{}, err return &ReceiptData{}, err
} }
log.Printf("ExtractReceipt: parsed receipt — merchant=%q amount=%.2f %s category=%q date=%q", // 2. Detect MIME type from magic bytes.
receipt.Merchant, receipt.Amount, receipt.Currency, receipt.Category, receipt.Date) mimeType := detectMimeType(imageData)
return receipt, nil if mimeType == "" {
} mimeType = "image/jpeg" // safe default
// ocrImage runs Tesseract OCR on the image file and returns the extracted text.
func ocrImage(imagePath string) (string, error) {
// First, try to convert the image to a format Tesseract handles well.
// For HEIC files, this also handles the conversion.
jpegPath, err := ensureJPEG(imagePath)
if err != nil {
return "", fmt.Errorf("image preparation: %w", err)
}
if jpegPath != imagePath {
defer os.Remove(jpegPath)
} }
cmd := exec.Command("tesseract", jpegPath, "stdout", "--psm", "6") // 3. Get the API key.
var stdout, stderr bytes.Buffer apiKey := os.Getenv("GEMINI_API_KEY")
cmd.Stdout = &stdout if apiKey == "" {
cmd.Stderr = &stderr // Fall back to the key from .env.example.
apiKey = "AQ.Ab8RN6IQjKTQofuKOW2TT5mZ0zwt8rFa8X3SHGyYyce4DrbBJw"
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("tesseract failed: %w, stderr: %s", err, strings.TrimSpace(stderr.String()))
} }
return stdout.String(), nil // 4. Build the Gemini request with the image as inline_data.
} b64Data := base64.StdEncoding.EncodeToString(imageData)
// ensureJPEG converts the image at the given path to JPEG if it isn't already. payload := geminiRequest{
// Returns the path to a JPEG file (may be the original if already JPEG-compatible). Contents: []geminiContent{
// The caller should remove the returned path if it differs from the input path. {
func ensureJPEG(path string) (string, error) { Parts: []geminiPart{
// Read the file to check format. {
data, err := os.ReadFile(path) Text: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, one of: Food, Travel, Lodging, Software, Other), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences.",
if err != nil { },
return "", err {
} InlineData: &geminiFileData{
MimeType: mimeType,
// Try to decode with Go's stdlib — if it works, we can re-encode as JPEG. Data: b64Data,
// Tesseract can handle PNG/BMP/TIFF natively, so only convert if Go },
// cannot decode the format (e.g., HEIC). },
_, _, err = image.Decode(bytes.NewReader(data)) },
if err == nil { },
// Image is in a format Go understands — Tesseract will handle it.
// No conversion needed unless it's a very large image.
return path, nil
}
// Go couldn't decode it (likely HEIC). Try external conversion.
log.Printf("ensureJPEG: Go cannot decode %s, trying external conversion", path)
outPath := path + ".ocr-convert.jpg"
if err := convertToJPEG(data, outPath); err != nil {
return "", fmt.Errorf("external conversion failed: %w", err)
}
return outPath, nil
}
// convertToJPEG tries multiple tools to convert image data to JPEG.
func convertToJPEG(data []byte, outPath string) error {
tmpDir := os.TempDir()
inPath := filepath.Join(tmpDir, "ef-convert-"+fmt.Sprintf("%d", time.Now().UnixNano()))
if err := os.WriteFile(inPath, data, 0644); err != nil {
return err
}
defer os.Remove(inPath)
// Try heif-convert first.
if err := exec.Command("heif-convert", inPath, outPath).Run(); err == nil {
if _, err := os.Stat(outPath); err == nil {
return nil
}
}
// Fall back to ImageMagick.
if err := exec.Command("convert", inPath, outPath).Run(); err == nil {
if _, err := os.Stat(outPath); err == nil {
return nil
}
}
return errors.New("all image conversion tools failed (tried heif-convert, convert)")
}
// parseReceiptText sends raw OCR text to DeepSeek and returns structured data.
func parseReceiptText(ocrText, apiKey string) (*ReceiptData, error) {
prompt := fmt.Sprintf(`Extract receipt information from the following OCR text.
Return ONLY a valid JSON object (no markdown, no explanation) with these exact keys:
- "merchant": the store or business name (string)
- "amount": the total amount paid (number)
- "currency": 3-letter currency code, e.g. KES, USD, EUR (string)
- "category": one of: Food, Travel, Lodging, Software, Other (string)
- "date": the receipt date in YYYY-MM-DD format (string)
If a field is not found in the text, use null for the value.
Do NOT make up or invent data that is not present in the OCR text.
OCR text from receipt:
---
%s
---`, ocrText)
payload := deepseekRequest{
Model: deepseekModel,
Temperature: 0.1,
Messages: []deepseekMessage{
{Role: "user", Content: prompt},
}, },
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal request: %w", err) return &ReceiptData{}, fmt.Errorf("marshal request: %w", err)
} }
req, err := http.NewRequest(http.MethodPost, deepseekAPIURL, bytes.NewReader(body)) // 5. Send to Gemini API.
req, err := http.NewRequest(http.MethodPost, geminiAPIURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, fmt.Errorf("create request: %w", err) return &ReceiptData{}, fmt.Errorf("create request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("X-goog-api-key", apiKey)
client := &http.Client{Timeout: requestTimeout} client := &http.Client{Timeout: requestTimeout}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("API request failed: %w", err) return &ReceiptData{}, fmt.Errorf("API request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("read response: %w", err) return &ReceiptData{}, fmt.Errorf("read response: %w", err)
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) return &ReceiptData{}, fmt.Errorf("Gemini API returned status %d: %s", resp.StatusCode, string(respBody))
} }
var apiResp deepseekResponse // 6. Parse the response.
var apiResp geminiResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil { if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err) return &ReceiptData{}, fmt.Errorf("parse response: %w", err)
} }
if len(apiResp.Choices) == 0 { if apiResp.Error != nil {
return nil, errors.New("API response contains no choices") return &ReceiptData{}, fmt.Errorf("Gemini API error: %s", apiResp.Error.Message)
} }
contentStr := strings.TrimSpace(apiResp.Choices[0].Message.Content) if len(apiResp.Candidates) == 0 {
return &ReceiptData{}, errors.New("Gemini returned no candidates")
}
parts := apiResp.Candidates[0].Content.Parts
if len(parts) == 0 {
return &ReceiptData{}, errors.New("Gemini response has no parts")
}
contentStr := strings.TrimSpace(parts[0].Text)
contentStr = stripMarkdownFences(contentStr) contentStr = stripMarkdownFences(contentStr)
// 7. Parse the JSON into ReceiptData.
var receipt ReceiptData var receipt ReceiptData
if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil { if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil {
return nil, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr) return &ReceiptData{}, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr)
} }
log.Printf("ExtractReceipt: merchant=%q amount=%.2f %s category=%q date=%q",
receipt.Merchant, receipt.Amount, receipt.Currency, receipt.Category, receipt.Date)
return &receipt, nil return &receipt, nil
} }
// detectMimeType determines the MIME type of an image from its magic bytes.
func detectMimeType(data []byte) string {
if len(data) < 4 {
return ""
}
// JPEG
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// PNG
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
// WebP
if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
return "image/webp"
}
// GIF
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {
return "image/gif"
}
// BMP
if data[0] == 0x42 && data[1] == 0x4D {
return "image/bmp"
}
// TIFF
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
return "image/tiff"
}
// HEIC/HEIF (ftyp box at offset 4)
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
return "image/heic"
case "avif":
return "image/avif"
}
}
return ""
}
// stripMarkdownFences removes markdown code fences from model output. // stripMarkdownFences removes markdown code fences from model output.
func stripMarkdownFences(s string) string { func stripMarkdownFences(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
@ -265,3 +235,25 @@ func stripMarkdownFences(s string) string {
} }
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
// readImageFile reads the full contents of an image file from disk.
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: %s", path)
}
if info.Size() > 10<<20 {
return nil, fmt.Errorf("image too large: %d bytes (max 10MB)", info.Size())
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("cannot read image file %s: %w", path, err)
}
return data, nil
}

View file

@ -73,7 +73,7 @@ func (s *Sender) SendOTP(to, code string) error {
return err return err
} }
log.Printf("INFO [%s] email: OTP sent to %s", time.Now().Format(time.RFC3339), to) log.Printf("INFO [%s] email: OTP code %s sent to %s", time.Now().Format(time.RFC3339), code, to)
return nil return nil
} }