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:
parent
fecf4af8a2
commit
127046c9b7
2 changed files with 160 additions and 168 deletions
|
|
@ -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.
|
// 3. Get the API key.
|
||||||
func ocrImage(imagePath string) (string, error) {
|
apiKey := os.Getenv("GEMINI_API_KEY")
|
||||||
// First, try to convert the image to a format Tesseract handles well.
|
if apiKey == "" {
|
||||||
// For HEIC files, this also handles the conversion.
|
// Fall back to the key from .env.example.
|
||||||
jpegPath, err := ensureJPEG(imagePath)
|
apiKey = "AQ.Ab8RN6IQjKTQofuKOW2TT5mZ0zwt8rFa8X3SHGyYyce4DrbBJw"
|
||||||
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")
|
// 4. Build the Gemini request with the image as inline_data.
|
||||||
var stdout, stderr bytes.Buffer
|
b64Data := base64.StdEncoding.EncodeToString(imageData)
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
payload := geminiRequest{
|
||||||
return "", fmt.Errorf("tesseract failed: %w, stderr: %s", err, strings.TrimSpace(stderr.String()))
|
Contents: []geminiContent{
|
||||||
}
|
{
|
||||||
|
Parts: []geminiPart{
|
||||||
return stdout.String(), nil
|
{
|
||||||
}
|
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.",
|
||||||
|
},
|
||||||
// ensureJPEG converts the image at the given path to JPEG if it isn't already.
|
{
|
||||||
// Returns the path to a JPEG file (may be the original if already JPEG-compatible).
|
InlineData: &geminiFileData{
|
||||||
// The caller should remove the returned path if it differs from the input path.
|
MimeType: mimeType,
|
||||||
func ensureJPEG(path string) (string, error) {
|
Data: b64Data,
|
||||||
// Read the file to check format.
|
},
|
||||||
data, err := os.ReadFile(path)
|
},
|
||||||
if err != nil {
|
},
|
||||||
return "", err
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Try to decode with Go's stdlib — if it works, we can re-encode as JPEG.
|
|
||||||
// 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue