- Replace events+expenses with flat purchases table - Add warranty_months, return_days, product_name fields - Remove currency conversion, CSV/PDF reporting, event filing - Simplify auth (no onboarding/department/profile) - Update AI extraction prompts for product/warranty info - Update all branding: templates, install.sh, Makefile, service file
145 lines
4.2 KiB
Go
145 lines
4.2 KiB
Go
package ai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const openaiTimeout = 30 * time.Second
|
|
|
|
type openaiRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []openaiMessage `json:"messages"`
|
|
Temperature float64 `json:"temperature"`
|
|
}
|
|
|
|
type openaiMessage struct {
|
|
Role string `json:"role"`
|
|
Content []openaiContent `json:"content"`
|
|
}
|
|
|
|
type openaiContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ImageURL *openaiImage `json:"image_url,omitempty"`
|
|
}
|
|
|
|
type openaiImage struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type openaiResponse struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
type openaiProvider struct {
|
|
apiKey string
|
|
model string
|
|
baseURL string
|
|
}
|
|
|
|
func newOpenAIProvider() openaiProvider {
|
|
return openaiProvider{
|
|
apiKey: os.Getenv("OPENAI_API_KEY"),
|
|
model: envOrDefault("AI_MODEL", "gpt-4o-mini"),
|
|
baseURL: strings.TrimRight(envOrDefault("AI_BASE_URL", "https://api.openai.com/v1"), "/"),
|
|
}
|
|
}
|
|
|
|
func (p openaiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) {
|
|
if p.apiKey == "" {
|
|
return &ReceiptData{}, errors.New("OPENAI_API_KEY environment variable not set")
|
|
}
|
|
|
|
imageData, err := readFile(imagePath)
|
|
if err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("read file: %w", err)
|
|
}
|
|
|
|
mimeType := detectMimeType(imageData)
|
|
if mimeType == "" {
|
|
mimeType = "image/jpeg"
|
|
}
|
|
|
|
b64Data := base64.StdEncoding.EncodeToString(imageData)
|
|
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
|
|
|
|
payload := openaiRequest{
|
|
Model: p.model,
|
|
Temperature: 0.1,
|
|
Messages: []openaiMessage{{
|
|
Role: "user",
|
|
Content: []openaiContent{
|
|
{Type: "text", Text: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"product_name\" (string, product or item name), \"merchant\" (string, store name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, one of: Electronics, Furniture, Appliances, Clothing, Home, Other), \"date\" (string, YYYY-MM-DD), \"warranty_months\" (number, 0 if not visible), \"return_days\" (number, 0 if not visible). Return ONLY valid JSON. No markdown, no explanation, no code fences."},
|
|
{Type: "image_url", ImageURL: &openaiImage{URL: dataURL}},
|
|
},
|
|
}},
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
apiURL := p.baseURL + "/chat/completions"
|
|
req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
|
|
client := &http.Client{Timeout: openaiTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("API request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return &ReceiptData{}, fmt.Errorf("API status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
|
}
|
|
|
|
var apiResp openaiResponse
|
|
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("parse response: %w", err)
|
|
}
|
|
|
|
if apiResp.Error != nil {
|
|
return &ReceiptData{}, fmt.Errorf("API error: %s", apiResp.Error.Message)
|
|
}
|
|
if len(apiResp.Choices) == 0 {
|
|
return &ReceiptData{}, errors.New("no response choices from API")
|
|
}
|
|
|
|
contentStr := stripMarkdownFences(apiResp.Choices[0].Message.Content)
|
|
var receipt ReceiptData
|
|
if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr)
|
|
}
|
|
|
|
log.Printf("ExtractReceipt [openai-%s]: merchant=%q amount=%.2f %s",
|
|
p.model, receipt.Merchant, receipt.Amount, receipt.Currency)
|
|
return &receipt, nil
|
|
}
|