fix: Ollama timeout + image compression + selective sudo in install.sh

This commit is contained in:
Claus Lohmar 2026-05-30 15:41:47 +00:00
parent 357483cfd2
commit b6c2c3f99d
2 changed files with 102 additions and 32 deletions

View file

@ -4,10 +4,12 @@
# ===================== # =====================
# #
# Usage: # Usage:
# sudo ./install.sh — Full install (clone, build, configure, service) # curl -fsSL https://git.lohmar.co.uk/cclohmar/ReceiptNext/raw/branch/main/install.sh | bash
# sudo ./install.sh -update — Pull latest, rebuild, restart # bash install.sh — Full install
# bash install.sh -update — Pull latest, rebuild, restart
# #
# Installs to /opt/receiptnext/ and sets up a systemd service. # Installs to /opt/receiptnext/ and sets up a systemd service.
# Uses sudo internally only for operations that require it.
# #
set -euo pipefail set -euo pipefail
@ -27,10 +29,14 @@ warn() { echo -e "${YELLOW}[!]${NC} $1"; }
err() { echo -e "${RED}[✗]${NC} $1"; } err() { echo -e "${RED}[✗]${NC} $1"; }
ask() { echo -en "${CYAN}${NC} $1"; } ask() { echo -en "${CYAN}${NC} $1"; }
if [ "$EUID" -ne 0 ]; then # Helper: run a command with sudo if not already root
err "Please run as root: sudo ./install.sh" sudo_if() {
exit 1 if [ "$EUID" -eq 0 ]; then
"$@"
else
sudo "$@"
fi fi
}
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# UPDATE MODE # UPDATE MODE
@ -51,13 +57,13 @@ if [ "${1:-}" = "-update" ]; then
if [ -d .git ]; then if [ -d .git ]; then
info "Pulling latest code..." info "Pulling latest code..."
git pull sudo_if git pull
else else
warn "Not a git repository — re-cloning..." warn "Not a git repository — re-cloning..."
cd /tmp cd /tmp
rm -rf receiptnext-update rm -rf receiptnext-update
git clone "$REPO_URL" receiptnext-update sudo_if git clone "$REPO_URL" receiptnext-update
rsync -a --delete receiptnext-update/ "$INSTALL_DIR/" sudo_if rsync -a --delete receiptnext-update/ "$INSTALL_DIR/"
rm -rf receiptnext-update rm -rf receiptnext-update
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
fi fi
@ -90,10 +96,10 @@ echo ""
if [ -d "$INSTALL_DIR" ]; then if [ -d "$INSTALL_DIR" ]; then
warn "$INSTALL_DIR already exists — pulling latest..." warn "$INSTALL_DIR already exists — pulling latest..."
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
git pull sudo_if git pull
else else
info "Cloning repository to $INSTALL_DIR..." info "Cloning repository to $INSTALL_DIR..."
git clone "$REPO_URL" "$INSTALL_DIR" sudo_if git clone "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
fi fi
@ -103,8 +109,8 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o receiptnext .
info "Binary built: $INSTALL_DIR/receiptnext" info "Binary built: $INSTALL_DIR/receiptnext"
# ── 3. Create runtime directories ──────────────────────────────── # ── 3. Create runtime directories ────────────────────────────────
mkdir -p storage sudo_if mkdir -p storage
chmod 755 templates static storage sudo_if chmod 755 templates static storage
# ── 4. AI Provider ─────────────────────────────────────────────── # ── 4. AI Provider ───────────────────────────────────────────────
echo "" echo ""
@ -137,20 +143,20 @@ case "$AI_CHOICE" in
if ! command -v zstd &>/dev/null; then if ! command -v zstd &>/dev/null; then
info "Installing zstd (required by Ollama)..." info "Installing zstd (required by Ollama)..."
if command -v apt-get &>/dev/null; then if command -v apt-get &>/dev/null; then
apt-get update -qq && apt-get install -y -qq zstd 2>&1 | tail -1 sudo_if apt-get update -qq && apt-get install -y -qq zstd 2>&1 | tail -1
elif command -v dnf &>/dev/null; then elif command -v dnf &>/dev/null; then
dnf install -y zstd 2>&1 | tail -1 sudo_if dnf install -y zstd 2>&1 | tail -1
elif command -v yum &>/dev/null; then elif command -v yum &>/dev/null; then
yum install -y zstd 2>&1 | tail -1 sudo_if yum install -y zstd 2>&1 | tail -1
elif command -v apk &>/dev/null; then elif command -v apk &>/dev/null; then
apk add zstd 2>&1 | tail -1 sudo_if apk add zstd 2>&1 | tail -1
else else
warn "Please install zstd manually, then re-run install.sh" warn "Please install zstd manually, then re-run install.sh"
fi fi
fi fi
info "Installing Ollama..." info "Installing Ollama..."
if ! command -v ollama &>/dev/null; then if ! command -v ollama &>/dev/null; then
curl -fsSL https://ollama.com/install.sh | sh 2>&1 | tail -5 sudo_if sh -c "$(curl -fsSL https://ollama.com/install.sh)" 2>&1 | tail -5
else else
info "Ollama already installed" info "Ollama already installed"
fi fi
@ -160,7 +166,7 @@ case "$AI_CHOICE" in
sleep 2 sleep 2
done done
info "Pulling model $AI_MODEL (this may take a while)..." info "Pulling model $AI_MODEL (this may take a while)..."
ollama pull "$AI_MODEL" 2>&1 | tail -3 sudo_if ollama pull "$AI_MODEL" 2>&1 | tail -3
AI_BASE_URL="http://localhost:11434" AI_BASE_URL="http://localhost:11434"
;; ;;
*) *)
@ -213,7 +219,7 @@ fi
# ── 7. Write .env ──────────────────────────────────────────────── # ── 7. Write .env ────────────────────────────────────────────────
info "Creating .env..." info "Creating .env..."
cat > "$INSTALL_DIR/.env" << ENVEOF sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF
# ReceiptNext Configuration # ReceiptNext Configuration
# Generated by install.sh on $(date) # Generated by install.sh on $(date)
@ -224,27 +230,27 @@ ENVEOF
case "$AI_PROVIDER" in case "$AI_PROVIDER" in
openai) openai)
cat >> "$INSTALL_DIR/.env" << ENVEOF sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
OPENAI_API_KEY=${OPENAI_API_KEY} OPENAI_API_KEY=${OPENAI_API_KEY}
AI_MODEL=${AI_MODEL} AI_MODEL=${AI_MODEL}
AI_BASE_URL=${AI_BASE_URL} AI_BASE_URL=${AI_BASE_URL}
ENVEOF ENVEOF
;; ;;
ollama) ollama)
cat >> "$INSTALL_DIR/.env" << ENVEOF sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
AI_MODEL=${AI_MODEL} AI_MODEL=${AI_MODEL}
AI_BASE_URL=${AI_BASE_URL} AI_BASE_URL=${AI_BASE_URL}
ENVEOF ENVEOF
;; ;;
gemini) gemini)
cat >> "$INSTALL_DIR/.env" << ENVEOF sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
GEMINI_API_KEY=${GEMINI_API_KEY} GEMINI_API_KEY=${GEMINI_API_KEY}
ENVEOF ENVEOF
;; ;;
esac esac
if [ -n "$SMTP_HOST" ]; then if [ -n "$SMTP_HOST" ]; then
cat >> "$INSTALL_DIR/.env" << ENVEOF sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
SMTP_HOST=${SMTP_HOST} SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT} SMTP_PORT=${SMTP_PORT}
SMTP_USER=${SMTP_USER} SMTP_USER=${SMTP_USER}
@ -252,12 +258,12 @@ SMTP_PASS=${SMTP_PASS}
ENVEOF ENVEOF
fi fi
chmod 600 "$INSTALL_DIR/.env" sudo_if chmod 600 "$INSTALL_DIR/.env"
info "Configuration saved to $INSTALL_DIR/.env" info "Configuration saved to $INSTALL_DIR/.env"
# ── 8. Systemd service ─────────────────────────────────────────── # ── 8. Systemd service ───────────────────────────────────────────
info "Creating systemd service..." info "Creating systemd service..."
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SERVEOF sudo_if tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null << SERVEOF
[Unit] [Unit]
Description=ReceiptNext — AI-Powered Expense Tracker Description=ReceiptNext — AI-Powered Expense Tracker
Documentation=https://git.lohmar.co.uk/cclohmar/ReceiptNext Documentation=https://git.lohmar.co.uk/cclohmar/ReceiptNext
@ -279,13 +285,13 @@ StandardError=append:/var/log/receiptnext.log
WantedBy=multi-user.target WantedBy=multi-user.target
SERVEOF SERVEOF
systemctl daemon-reload sudo_if systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" sudo_if systemctl enable "${SERVICE_NAME}"
info "Service created: /etc/systemd/system/${SERVICE_NAME}.service" info "Service created: /etc/systemd/system/${SERVICE_NAME}.service"
# ── 9. Start ───────────────────────────────────────────────────── # ── 9. Start ─────────────────────────────────────────────────────
info "Starting ReceiptNext..." info "Starting ReceiptNext..."
systemctl restart "${SERVICE_NAME}" 2>/dev/null || true sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true
sleep 2 sleep 2
echo "" echo ""

View file

@ -5,6 +5,8 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image"
"image/jpeg"
"io" "io"
"log" "log"
"net/http" "net/http"
@ -13,7 +15,11 @@ import (
"time" "time"
) )
const ollamaTimeout = 60 * time.Second const (
ollamaTimeout = 300 * time.Second
ollamaMaxSize = 800
ollamaQuality = 60
)
type ollamaRequest struct { type ollamaRequest struct {
Model string `json:"model"` Model string `json:"model"`
@ -43,6 +49,15 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
return &ReceiptData{}, fmt.Errorf("read file: %w", err) 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") baseURL := os.Getenv("AI_BASE_URL")
if baseURL == "" { if baseURL == "" {
baseURL = "http://localhost:11434" baseURL = "http://localhost:11434"
@ -51,7 +66,7 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
model := os.Getenv("AI_MODEL") model := os.Getenv("AI_MODEL")
if model == "" { if model == "" {
model = "llava" model = "qwen3.5:2b"
} }
b64Data := base64.StdEncoding.EncodeToString(imageData) b64Data := base64.StdEncoding.EncodeToString(imageData)
@ -61,9 +76,10 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
Stream: false, Stream: false,
Messages: []ollamaMessage{{ Messages: []ollamaMessage{{
Role: "user", Role: "user",
Content: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string), \"amount\" (number), \"currency\" (string, 3-letter code), \"category\" (string, one of: Food, Travel, Lodging, Software, Other), \"date\" (string, YYYY-MM-DD). Return ONLY valid JSON. No markdown.", 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}, Images: []string{b64Data},
}}, }},
Options: map[string]any{"num_predict": 256},
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@ -100,3 +116,51 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
model, receipt.Merchant, receipt.Amount, receipt.Currency) model, receipt.Merchant, receipt.Amount, receipt.Currency)
return &receipt, nil 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
}