From 9f52198647b1ded10589068ad344346fcc2ed7b9 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Sat, 30 May 2026 17:40:40 +0000 Subject: [PATCH] fix: remove orphaned fallthrough after ollama removal --- .env.example | 11 +-- README.md | 6 -- install.sh | 72 +---------------- internal/ai/ollama.go | 171 ----------------------------------------- internal/ai/receipt.go | 8 +- 5 files changed, 9 insertions(+), 259 deletions(-) delete mode 100644 internal/ai/ollama.go diff --git a/.env.example b/.env.example index eec0064..a3eb775 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,19 @@ # ReceiptNext Configuration # 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 --- -# Choose one: gemini, openai, ollama +# Choose one: gemini (default), openai AI_PROVIDER=gemini # For AI_PROVIDER=gemini: # 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-... # AI_MODEL=gpt-4o-mini # AI_BASE_URL=https://api.openai.com/v1 - -# For AI_PROVIDER=ollama: -# AI_BASE_URL=http://localhost:11434 -# AI_MODEL=glm-ocr +# For Ollama: AI_BASE_URL=http://localhost:11434, AI_MODEL=glm-ocr # --- SMTP (optional — needed for OTP emails and report delivery) --- # SMTP_HOST=smtp.example.com diff --git a/README.md b/README.md index e191fa4..d2a0315 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # 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) 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) @@ -107,8 +104,6 @@ Configuration is via environment variables in `.env`. The install script builds | `AI_PROVIDER` | No | `gemini` | `gemini`, `openai`, or `ollama` | | `GEMINI_API_KEY` | For Gemini | — | Google Gemini 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_HOST` | See note | — | SMTP server hostname | | `SMTP_PORT` | See note | `587` | SMTP server port | @@ -212,7 +207,6 @@ Navigate to `http://YOUR_SERVER:8080` ### 🤖 AI Receipt Extraction - **Google Gemini** (default) — cloud vision API - **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.) ### 💱 Currency Conversion diff --git a/install.sh b/install.sh index 45b68c1..a9bca3f 100755 --- a/install.sh +++ b/install.sh @@ -155,17 +155,16 @@ echo "" echo " ── AI Provider ──" echo " Which AI should process receipt images?" echo " 1) Google Gemini (cloud API, needs API key)" -echo " 2) OpenAI / Compatible (OpenAI, Perplexity, Groq, etc.)" -echo " 3) Ollama (local, installs automatically)" +echo " 2) OpenAI / Compatible (also works with Ollama, LocalAI, etc.)" echo "" -ask " Choose [1-3] (default: 1): " +ask " Choose [1-2] (default: 1): " read -r AI_CHOICE AI_CHOICE="${AI_CHOICE:-1}" case "$AI_CHOICE" in 2) AI_PROVIDER="openai" - ask " OpenAI API key: " + ask " API key (or press Enter for Ollama): " read -r OPENAI_API_KEY ask " Model [gpt-4o-mini]: " read -r AI_MODEL @@ -174,61 +173,6 @@ case "$AI_CHOICE" in read -r AI_BASE_URL 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" ask " Gemini API key: " @@ -294,12 +238,6 @@ case "$AI_PROVIDER" in OPENAI_API_KEY=${OPENAI_API_KEY} AI_MODEL=${AI_MODEL} 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 ;; gemini) @@ -333,10 +271,6 @@ if [ "$APP_USER" = "root" ]; then fi else 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 # ── 9. Set ownership ─────────────────────────────────────────────── diff --git a/internal/ai/ollama.go b/internal/ai/ollama.go deleted file mode 100644 index df15151..0000000 --- a/internal/ai/ollama.go +++ /dev/null @@ -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 -} diff --git a/internal/ai/receipt.go b/internal/ai/receipt.go index 508ce76..e4ee498 100644 --- a/internal/ai/receipt.go +++ b/internal/ai/receipt.go @@ -1,11 +1,11 @@ // 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: // // AI_PROVIDER=gemini (default, uses GEMINI_API_KEY) // 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 import ( @@ -42,10 +42,6 @@ func getProvider() Provider { switch providerName { case "openai": return &openaiProvider{} - case "ollama": - return &ollamaProvider{} - case "gemini": - fallthrough default: return &geminiProvider{} }