fix: remove orphaned fallthrough after ollama removal
This commit is contained in:
parent
3327fd4fac
commit
9f52198647
5 changed files with 9 additions and 259 deletions
11
.env.example
11
.env.example
|
|
@ -1,22 +1,19 @@
|
||||||
# ReceiptNext Configuration
|
# ReceiptNext Configuration
|
||||||
# Copy this file to .env and fill in your credentials.
|
# Copy this file to .env and fill in your credentials.
|
||||||
# Run `sudo ./install.sh` for interactive setup.
|
# Run `bash install.sh` for interactive setup.
|
||||||
|
|
||||||
# --- AI Provider ---
|
# --- AI Provider ---
|
||||||
# Choose one: gemini, openai, ollama
|
# Choose one: gemini (default), openai
|
||||||
AI_PROVIDER=gemini
|
AI_PROVIDER=gemini
|
||||||
|
|
||||||
# For AI_PROVIDER=gemini:
|
# For AI_PROVIDER=gemini:
|
||||||
# GEMINI_API_KEY=your-gemini-api-key
|
# GEMINI_API_KEY=your-gemini-api-key
|
||||||
|
|
||||||
# For AI_PROVIDER=openai:
|
# For AI_PROVIDER=openai (also works with Ollama, LocalAI, etc.):
|
||||||
# OPENAI_API_KEY=sk-...
|
# OPENAI_API_KEY=sk-...
|
||||||
# AI_MODEL=gpt-4o-mini
|
# AI_MODEL=gpt-4o-mini
|
||||||
# AI_BASE_URL=https://api.openai.com/v1
|
# AI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# For Ollama: AI_BASE_URL=http://localhost:11434, AI_MODEL=glm-ocr
|
||||||
# For AI_PROVIDER=ollama:
|
|
||||||
# AI_BASE_URL=http://localhost:11434
|
|
||||||
# AI_MODEL=glm-ocr
|
|
||||||
|
|
||||||
# --- SMTP (optional — needed for OTP emails and report delivery) ---
|
# --- SMTP (optional — needed for OTP emails and report delivery) ---
|
||||||
# SMTP_HOST=smtp.example.com
|
# SMTP_HOST=smtp.example.com
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
# ReceiptNext — AI-Powered Expense Tracker
|
# ReceiptNext — AI-Powered Expense Tracker
|
||||||
|
|
||||||
> A production-ready, mobile-first Progressive Web App (PWA) for expense management with passwordless OTP login, AI receipt extraction (Gemini / OpenAI / Ollama), and CSV/PDF email reporting with receipt images.
|
|
||||||
|
|
||||||
**Tech Stack:** Go 1.23+ · HTMX · SQLite (pure Go) · Google Gemini / OpenAI / Ollama · PWA
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -27,7 +25,6 @@ The installer will:
|
||||||
```
|
```
|
||||||
1) Google Gemini (cloud API, needs API key)
|
1) Google Gemini (cloud API, needs API key)
|
||||||
2) OpenAI / Compatible (OpenAI, Perplexity, Groq, etc.)
|
2) OpenAI / Compatible (OpenAI, Perplexity, Groq, etc.)
|
||||||
3) Ollama (local — installs Ollama + qwen3.5:2b automatically)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Prompt for SMTP settings (for OTP emails and report delivery)
|
4. Prompt for SMTP settings (for OTP emails and report delivery)
|
||||||
|
|
@ -107,8 +104,6 @@ Configuration is via environment variables in `.env`. The install script builds
|
||||||
| `AI_PROVIDER` | No | `gemini` | `gemini`, `openai`, or `ollama` |
|
| `AI_PROVIDER` | No | `gemini` | `gemini`, `openai`, or `ollama` |
|
||||||
| `GEMINI_API_KEY` | For Gemini | — | Google Gemini API key |
|
| `GEMINI_API_KEY` | For Gemini | — | Google Gemini API key |
|
||||||
| `OPENAI_API_KEY` | For OpenAI | — | OpenAI-compatible API key |
|
| `OPENAI_API_KEY` | For OpenAI | — | OpenAI-compatible API key |
|
||||||
| `AI_MODEL` | For OpenAI/Ollama | `gpt-4o-mini` / `qwen3.5:2b` | Model name |
|
|
||||||
| `AI_BASE_URL` | For OpenAI/Ollama | `https://api.openai.com/v1` | API endpoint |
|
|
||||||
| **SMTP** | | | |
|
| **SMTP** | | | |
|
||||||
| `SMTP_HOST` | See note | — | SMTP server hostname |
|
| `SMTP_HOST` | See note | — | SMTP server hostname |
|
||||||
| `SMTP_PORT` | See note | `587` | SMTP server port |
|
| `SMTP_PORT` | See note | `587` | SMTP server port |
|
||||||
|
|
@ -212,7 +207,6 @@ Navigate to `http://YOUR_SERVER:8080`
|
||||||
### 🤖 AI Receipt Extraction
|
### 🤖 AI Receipt Extraction
|
||||||
- **Google Gemini** (default) — cloud vision API
|
- **Google Gemini** (default) — cloud vision API
|
||||||
- **OpenAI-compatible** — works with OpenAI, Perplexity, Groq, Together AI, etc.
|
- **OpenAI-compatible** — works with OpenAI, Perplexity, Groq, Together AI, etc.
|
||||||
- **Ollama** — local, offline, no API key needed (uses `qwen3.5:2b`)
|
|
||||||
- Supports: JPEG, PNG, WebP, HEIC, PDF (email receipts from Uber, etc.)
|
- Supports: JPEG, PNG, WebP, HEIC, PDF (email receipts from Uber, etc.)
|
||||||
|
|
||||||
### 💱 Currency Conversion
|
### 💱 Currency Conversion
|
||||||
|
|
|
||||||
72
install.sh
72
install.sh
|
|
@ -155,17 +155,16 @@ echo ""
|
||||||
echo " ── AI Provider ──"
|
echo " ── AI Provider ──"
|
||||||
echo " Which AI should process receipt images?"
|
echo " Which AI should process receipt images?"
|
||||||
echo " 1) Google Gemini (cloud API, needs API key)"
|
echo " 1) Google Gemini (cloud API, needs API key)"
|
||||||
echo " 2) OpenAI / Compatible (OpenAI, Perplexity, Groq, etc.)"
|
echo " 2) OpenAI / Compatible (also works with Ollama, LocalAI, etc.)"
|
||||||
echo " 3) Ollama (local, installs automatically)"
|
|
||||||
echo ""
|
echo ""
|
||||||
ask " Choose [1-3] (default: 1): "
|
ask " Choose [1-2] (default: 1): "
|
||||||
read -r AI_CHOICE
|
read -r AI_CHOICE
|
||||||
AI_CHOICE="${AI_CHOICE:-1}"
|
AI_CHOICE="${AI_CHOICE:-1}"
|
||||||
|
|
||||||
case "$AI_CHOICE" in
|
case "$AI_CHOICE" in
|
||||||
2)
|
2)
|
||||||
AI_PROVIDER="openai"
|
AI_PROVIDER="openai"
|
||||||
ask " OpenAI API key: "
|
ask " API key (or press Enter for Ollama): "
|
||||||
read -r OPENAI_API_KEY
|
read -r OPENAI_API_KEY
|
||||||
ask " Model [gpt-4o-mini]: "
|
ask " Model [gpt-4o-mini]: "
|
||||||
read -r AI_MODEL
|
read -r AI_MODEL
|
||||||
|
|
@ -174,61 +173,6 @@ case "$AI_CHOICE" in
|
||||||
read -r AI_BASE_URL
|
read -r AI_BASE_URL
|
||||||
AI_BASE_URL="${AI_BASE_URL:-https://api.openai.com/v1}"
|
AI_BASE_URL="${AI_BASE_URL:-https://api.openai.com/v1}"
|
||||||
;;
|
;;
|
||||||
3)
|
|
||||||
AI_PROVIDER="ollama"
|
|
||||||
AI_MODEL="glm-ocr"
|
|
||||||
# Ollama's install script requires zstd
|
|
||||||
if ! command -v zstd &>/dev/null; then
|
|
||||||
info "Installing zstd (required by Ollama)..."
|
|
||||||
if command -v apt-get &>/dev/null; then
|
|
||||||
sudo_if apt-get update -qq && apt-get install -y -qq zstd 2>&1 | tail -1
|
|
||||||
elif command -v dnf &>/dev/null; then
|
|
||||||
sudo_if dnf install -y zstd 2>&1 | tail -1
|
|
||||||
elif command -v yum &>/dev/null; then
|
|
||||||
sudo_if yum install -y zstd 2>&1 | tail -1
|
|
||||||
elif command -v apk &>/dev/null; then
|
|
||||||
sudo_if apk add zstd 2>&1 | tail -1
|
|
||||||
else
|
|
||||||
warn "Please install zstd manually, then re-run install.sh"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
info "Installing Ollama..."
|
|
||||||
if ! command -v ollama &>/dev/null; then
|
|
||||||
sudo_if sh -c "$(curl -fsSL https://ollama.com/install.sh)" 2>&1 | tail -5
|
|
||||||
# Fix permissions: the installer may leave /usr/share/ollama owned by root
|
|
||||||
# when run via sudo, but the ollama service runs as the ollama user
|
|
||||||
if id -u ollama &>/dev/null; then
|
|
||||||
sudo_if chown -R ollama:ollama /usr/share/ollama 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
info "Ollama already installed"
|
|
||||||
fi
|
|
||||||
# Wait for Ollama server to be ready (API endpoint, not just CLI)
|
|
||||||
info "Waiting for Ollama server..."
|
|
||||||
for i in $(seq 1 15); do
|
|
||||||
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null 2>&1; then
|
|
||||||
info "Ollama server is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
info "Pulling model $AI_MODEL (this may take a while)..."
|
|
||||||
# Pull via the API URL so it works regardless of which user runs it
|
|
||||||
OLLAMA_HOST="http://127.0.0.1:11434"
|
|
||||||
export OLLAMA_HOST
|
|
||||||
if id -u ollama &>/dev/null; then
|
|
||||||
sudo -u ollama sh -c "OLLAMA_HOST=$OLLAMA_HOST ollama pull $AI_MODEL" 2>&1 | tail -3
|
|
||||||
else
|
|
||||||
ollama pull "$AI_MODEL" 2>&1 | tail -3
|
|
||||||
fi
|
|
||||||
# Warm-up: send a quick prompt to preload the model into memory
|
|
||||||
# so the first user request isn't delayed by model loading.
|
|
||||||
info "Warming up $AI_MODEL..."
|
|
||||||
curl -s --max-time 120 -X POST "$OLLAMA_HOST/api/chat" \
|
|
||||||
-d "{\"model\":\"$AI_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"say ok\"}],\"stream\":false,\"options\":{\"num_predict\":10}}" > /dev/null 2>&1 && \
|
|
||||||
info "Model ready" || warn "Model warm-up timed out (will load on first use)"
|
|
||||||
AI_BASE_URL="http://localhost:11434"
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
AI_PROVIDER="gemini"
|
AI_PROVIDER="gemini"
|
||||||
ask " Gemini API key: "
|
ask " Gemini API key: "
|
||||||
|
|
@ -294,12 +238,6 @@ case "$AI_PROVIDER" in
|
||||||
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
|
|
||||||
;;
|
|
||||||
ollama)
|
|
||||||
sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
|
|
||||||
AI_MODEL=${AI_MODEL}
|
|
||||||
AI_BASE_URL=${AI_BASE_URL}
|
|
||||||
ENVEOF
|
ENVEOF
|
||||||
;;
|
;;
|
||||||
gemini)
|
gemini)
|
||||||
|
|
@ -333,10 +271,6 @@ if [ "$APP_USER" = "root" ]; then
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
info "Using existing user '${APP_USER}'"
|
info "Using existing user '${APP_USER}'"
|
||||||
# Add user to ollama group if it exists (so they can run ollama CLI)
|
|
||||||
if getent group ollama &>/dev/null; then
|
|
||||||
sudo_if usermod -aG ollama "${APP_USER}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 9. Set ownership ───────────────────────────────────────────────
|
# ── 9. Set ownership ───────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
// Package ai provides receipt data extraction from images/PDFs using
|
// Package ai provides receipt data extraction from images/PDFs using
|
||||||
// configurable AI providers (Gemini, OpenAI-compatible, or Ollama).
|
// configurable AI providers (Gemini or OpenAI-compatible).
|
||||||
//
|
//
|
||||||
// Provider selection is done via environment variables:
|
// Provider selection is done via environment variables:
|
||||||
//
|
//
|
||||||
// AI_PROVIDER=gemini (default, uses GEMINI_API_KEY)
|
// AI_PROVIDER=gemini (default, uses GEMINI_API_KEY)
|
||||||
// AI_PROVIDER=openai (uses OPENAI_API_KEY, AI_MODEL, AI_BASE_URL)
|
// AI_PROVIDER=openai (uses OPENAI_API_KEY, AI_MODEL, AI_BASE_URL)
|
||||||
// AI_PROVIDER=ollama (uses AI_BASE_URL, AI_MODEL)
|
// (also works with Ollama, LocalAI, etc.)
|
||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -42,10 +42,6 @@ func getProvider() Provider {
|
||||||
switch providerName {
|
switch providerName {
|
||||||
case "openai":
|
case "openai":
|
||||||
return &openaiProvider{}
|
return &openaiProvider{}
|
||||||
case "ollama":
|
|
||||||
return &ollamaProvider{}
|
|
||||||
case "gemini":
|
|
||||||
fallthrough
|
|
||||||
default:
|
default:
|
||||||
return &geminiProvider{}
|
return &geminiProvider{}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue