NextExpense/internal/ai/gemini.go
cclohmar 5ca3ff7555 feat: configurable AI provider system (Gemini, OpenAI, Ollama)
- New provider architecture with common interface
- Provider selected via AI_PROVIDER env var (gemini/openai/ollama)
- Gemini (default): existing implementation, uses GEMINI_API_KEY
- OpenAI-compatible: uses OPENAI_API_KEY + AI_MODEL + AI_BASE_URL
  - Works with OpenAI, Perplexity, Together AI, Groq, etc.
- Ollama: local LLM, uses AI_BASE_URL + AI_MODEL
  - Supports llava, bakllava, and other vision models
- deepseek.go renamed to llm.go (cleanup)
- .env.example updated with all AI provider options
2026-05-30 14:05:19 +00:00

126 lines
3.3 KiB
Go

package ai
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
const (
geminiAPIURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:generateContent"
geminiTimeout = 30 * time.Second
)
type geminiRequest struct {
Contents []geminiContent `json:"contents"`
}
type geminiContent struct {
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text,omitempty"`
InlineData *geminiFileData `json:"inline_data,omitempty"`
}
type geminiFileData struct {
MimeType string `json:"mime_type"`
Data string `json:"data"`
}
type geminiResponse struct {
Candidates []geminiCandidate `json:"candidates"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
type geminiCandidate struct {
Content geminiResponseContent `json:"content"`
}
type geminiResponseContent struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
}
type geminiProvider struct{}
func (p *geminiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) {
imageData, err := readFile(imagePath)
if err != nil {
return &ReceiptData{}, fmt.Errorf("read file: %w", err)
}
mimeType := detectMimeType(imageData)
if mimeType == "" {
mimeType = "image/jpeg"
}
apiKey := os.Getenv("GEMINI_API_KEY")
if apiKey == "" {
apiKey = "AQ.Ab8RN6IQjKTQofuKOW2TT5mZ0zwt8rFa8X3SHGyYyce4DrbBJw"
}
b64Data := base64.StdEncoding.EncodeToString(imageData)
payload := geminiRequest{
Contents: []geminiContent{{
Parts: []geminiPart{
{Text: "Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (Food/Travel/Lodging/Software/Other), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown."},
{InlineData: &geminiFileData{MimeType: mimeType, Data: b64Data}},
},
}},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, geminiAPIURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-goog-api-key", apiKey)
client := &http.Client{Timeout: geminiTimeout}
resp, err := client.Do(req)
if err != nil {
return &ReceiptData{}, fmt.Errorf("API request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return &ReceiptData{}, fmt.Errorf("Gemini status %d: %s", resp.StatusCode, string(respBody))
}
var apiResp geminiResponse
json.Unmarshal(respBody, &apiResp)
if apiResp.Error != nil {
return &ReceiptData{}, fmt.Errorf("Gemini error: %s", apiResp.Error.Message)
}
if len(apiResp.Candidates) == 0 {
return &ReceiptData{}, errors.New("no candidates")
}
parts := apiResp.Candidates[0].Content.Parts
if len(parts) == 0 {
return &ReceiptData{}, errors.New("no response text")
}
contentStr := stripMarkdownFences(parts[0].Text)
var receipt ReceiptData
if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil {
return &ReceiptData{}, fmt.Errorf("parse JSON: %w (content: %s)", err, contentStr)
}
log.Printf("ExtractReceipt [gemini]: merchant=%q amount=%.2f %s category=%q date=%q",
receipt.Merchant, receipt.Amount, receipt.Currency, receipt.Category, receipt.Date)
return &receipt, nil
}