- glm-ocr is a 1.1B parameter model built specifically for OCR - No reasoning overhead, no thinking field issues - Faster inference than qwen3.5 on CPU - Removed old qwen3.5 models (2B + 0.8B) to free ~4GB disk - Updated install.sh, ollama.go, .env.example defaults
171 lines
4.4 KiB
Go
171 lines
4.4 KiB
Go
package ai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
ollamaTimeout = 300 * time.Second
|
|
ollamaMaxSize = 800
|
|
ollamaQuality = 60
|
|
)
|
|
|
|
type ollamaRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []ollamaMessage `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
Options map[string]any `json:"options,omitempty"`
|
|
}
|
|
|
|
type ollamaMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
Images []string `json:"images,omitempty"`
|
|
}
|
|
|
|
type ollamaResponse struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
Thinking string `json:"thinking"`
|
|
} `json:"message"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type ollamaProvider struct{}
|
|
|
|
func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) {
|
|
imageData, err := readFile(imagePath)
|
|
if err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("read file: %w", err)
|
|
}
|
|
|
|
// Compress the image before base64 encoding: resize to max 800px
|
|
// and JPEG-compress at quality 60 to keep the payload manageable
|
|
// for a local 2.3B model.
|
|
compressed, err := compressImageForOllama(imageData)
|
|
if err == nil && len(compressed) < len(imageData) {
|
|
imageData = compressed
|
|
log.Printf("ExtractReceipt [ollama]: compressed %d -> %d bytes", len(imageData), len(compressed))
|
|
}
|
|
|
|
baseURL := os.Getenv("AI_BASE_URL")
|
|
if baseURL == "" {
|
|
baseURL = "http://localhost:11434"
|
|
}
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
|
|
model := os.Getenv("AI_MODEL")
|
|
if model == "" {
|
|
model = "glm-ocr"
|
|
}
|
|
|
|
b64Data := base64.StdEncoding.EncodeToString(imageData)
|
|
|
|
payload := ollamaRequest{
|
|
Model: model,
|
|
Stream: false,
|
|
Messages: []ollamaMessage{{
|
|
Role: "user",
|
|
Content: "Analyze this receipt image. 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.",
|
|
Images: []string{b64Data},
|
|
}},
|
|
Options: map[string]any{"num_predict": 256},
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
apiURL := baseURL + "/api/chat"
|
|
req, _ := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: ollamaTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return &ReceiptData{}, fmt.Errorf("Ollama request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return &ReceiptData{}, fmt.Errorf("Ollama status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var apiResp ollamaResponse
|
|
json.Unmarshal(respBody, &apiResp)
|
|
|
|
if apiResp.Error != "" {
|
|
return &ReceiptData{}, fmt.Errorf("Ollama error: %s", apiResp.Error)
|
|
}
|
|
|
|
contentStr := apiResp.Message.Content
|
|
if contentStr == "" {
|
|
contentStr = apiResp.Message.Thinking
|
|
}
|
|
contentStr = stripMarkdownFences(contentStr)
|
|
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 [ollama-%s]: merchant=%q amount=%.2f %s",
|
|
model, receipt.Merchant, receipt.Amount, receipt.Currency)
|
|
return &receipt, nil
|
|
}
|
|
|
|
// compressImageForOllama resizes the image to fit within maxSize and
|
|
// re-encodes as JPEG at the given quality to reduce the base64 payload.
|
|
func compressImageForOllama(data []byte) ([]byte, error) {
|
|
img, _, err := image.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
w := bounds.Dx()
|
|
h := bounds.Dy()
|
|
|
|
// If already small enough, re-encode at lower quality
|
|
if w <= ollamaMaxSize && h <= ollamaMaxSize {
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: ollamaQuality}); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// Scale down
|
|
ratio := float64(ollamaMaxSize) / float64(max(w, h))
|
|
nw := int(float64(w) * ratio)
|
|
nh := int(float64(h) * ratio)
|
|
if nw < 1 {
|
|
nw = 1
|
|
}
|
|
if nh < 1 {
|
|
nh = 1
|
|
}
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
|
for y := 0; y < nh; y++ {
|
|
for x := 0; x < nw; x++ {
|
|
sx := x * w / nw
|
|
sy := y * h / nh
|
|
dst.Set(x, y, img.At(sx, sy))
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: ollamaQuality}); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|