From dd44a2c2b149cc5f6002bb76097d28d4f13e00e1 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 29 Jun 2026 22:29:28 +0200 Subject: [PATCH] Initial commit: SearXNG + Firecrawl + HHEM API stack Self-hosted Docker Compose stack for DGX Spark (ARM64/aarch64, CPU-only): - SearXNG on port 8889 with JSON API enabled, rate limiting off - Firecrawl (built from local source) on port 3002, no API key required - HHEM API (FastAPI + vectara/hallucination_evaluation_model) on port 8881 - Portainer stack YAML using dgx_net external network - build-images.sh to pre-build local images before Portainer deploy - HF model cache bind-mounted from /home/sparky/LLMs/huggingface Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 6 ++ Portainer-Stack.yaml | 149 ++++++++++++++++++++++++++ README.md | 241 +++++++++++++++++++++++++++++++++++++++++++ build-images.sh | 23 +++++ docker-compose.yml | 178 ++++++++++++++++++++++++++++++++ hhem-api/Dockerfile | 28 +++++ hhem-api/app.py | 76 ++++++++++++++ searxng/settings.yml | 93 +++++++++++++++++ 8 files changed, 794 insertions(+) create mode 100644 .gitignore create mode 100644 Portainer-Stack.yaml create mode 100644 README.md create mode 100755 build-images.sh create mode 100644 docker-compose.yml create mode 100644 hhem-api/Dockerfile create mode 100644 hhem-api/app.py create mode 100644 searxng/settings.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef4d2c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Docker build artifacts +*.log + +# Don't commit secrets or local overrides +.env +.env.local diff --git a/Portainer-Stack.yaml b/Portainer-Stack.yaml new file mode 100644 index 0000000..06e6e7f --- /dev/null +++ b/Portainer-Stack.yaml @@ -0,0 +1,149 @@ +services: + + # =========================================================================== + # 1. SearXNG — meta-search with JSON API + # =========================================================================== + searxng: + image: docker.io/searxng/searxng:latest + container_name: searxng-8889 + restart: unless-stopped + ports: + - "8889:8080" + volumes: + - /home/sparky/Docker/searXNG Firecrawl HHEM /searxng:/etc/searxng:rw + - searxng-cache:/var/cache/searxng:rw + environment: + - SEARXNG_BASE_URL=http://localhost:8889/ + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + networks: + - dgx_net + + # =========================================================================== + # 2. Firecrawl — web scraper (self-hosted, no API key required) + # Images must be pre-built: run build-images.sh before deploying this stack + # =========================================================================== + + firecrawl-redis: + image: redis:7-alpine + container_name: firecrawl-redis + restart: unless-stopped + command: redis-server --bind 0.0.0.0 --save 60 1 --loglevel warning + volumes: + - firecrawl-redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - dgx_net + + firecrawl-postgres: + image: firecrawl-postgres:local + container_name: firecrawl-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + volumes: + - firecrawl-pg-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - dgx_net + + firecrawl-playwright: + image: firecrawl-playwright:local + container_name: firecrawl-playwright + restart: unless-stopped + environment: + - PORT=3000 + shm_size: "2gb" + networks: + - dgx_net + + firecrawl-api: + image: firecrawl-api:local + container_name: firecrawl-api-3002 + restart: unless-stopped + ports: + - "3002:3002" + environment: + - USE_DB_AUTHENTICATION=false + - REDIS_URL=redis://firecrawl-redis:6379 + - REDIS_RATE_LIMIT_URL=redis://firecrawl-redis:6379 + - PLAYWRIGHT_MICROSERVICE_URL=http://firecrawl-playwright:3000/scrape + - NUQ_DATABASE_URL=postgres://postgres:postgres@firecrawl-postgres:5432/postgres + - HOST=0.0.0.0 + - PORT=3002 + - NODE_ENV=production + - BULL_AUTH_KEY=aitools-bull-changeme + - SEARXNG_ENDPOINT=http://searxng:8080 + command: node dist/src/harness.js --start-docker + extra_hosts: + - "host.docker.internal:host-gateway" + ulimits: + nofile: + soft: 65535 + hard: 65535 + depends_on: + - firecrawl-redis + - firecrawl-postgres + - firecrawl-playwright + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3002/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - dgx_net + + # =========================================================================== + # 3. HHEM API — Vectara hallucination evaluator (CPU-only, ARM64) + # Images must be pre-built: run build-images.sh before deploying this stack + # =========================================================================== + hhem-api: + image: hhem-api:local + container_name: hhem-api-8881 + restart: unless-stopped + ports: + - "8881:8881" + volumes: + # Bind-mount the shared HF model cache so the HHEM model is only + # downloaded once and lives alongside all other LLMs on this host. + - /home/sparky/LLMs/huggingface:/hf-cache + environment: + - HF_HOME=/hf-cache + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8881/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + networks: + - dgx_net + +volumes: + searxng-cache: + firecrawl-redis-data: + firecrawl-pg-data: + +networks: + dgx_net: + external: true diff --git a/README.md b/README.md new file mode 100644 index 0000000..100c61a --- /dev/null +++ b/README.md @@ -0,0 +1,241 @@ +# AI Tools Stack — SearXNG + Firecrawl + HHEM + +Self-hosted Docker Compose stack for the DGX Spark (ARM64/aarch64, CPU-only). +Runs alongside the existing n8n automation stack and exposes three services for agent use. + +## Quick Start + +```bash +cd ~/Docker/searXNG\ Firecrawl\ HHEM\ \ + +# First run: build Firecrawl from source (takes ~5–10 min) +docker compose build + +# Start everything +docker compose up -d + +# Check status +docker compose ps +``` + +## Services & Ports + +| Service | Port | Container name | Purpose | +|------------|------|-------------------|---------| +| SearXNG | 8888 | `searxng` | Meta-search (JSON API) | +| Firecrawl | 3002 | `firecrawl-api` | Web scraper / crawler | +| HHEM API | 8881 | `hhem-api` | Hallucination evaluator | + +All services share the `ai-tools` Docker network. +n8n reaches them at `http://host.docker.internal:` (or `http://:`). + +--- + +## 1. SearXNG — Meta-Search + +**Base URL:** `http://localhost:8888` + +### Search (JSON) + +``` +GET /search?q=&format=json&categories=general +``` + +**Example curl:** +```bash +curl "http://localhost:8888/search?q=Claude+AI&format=json" | jq '.results[0]' +``` + +**Response shape:** +```json +{ + "query": "Claude AI", + "results": [ + { + "title": "...", + "url": "https://...", + "content": "...", + "engine": "google", + "score": 1.0 + } + ] +} +``` + +### n8n HTTP Request node + +| Field | Value | +|-------|-------| +| Method | GET | +| URL | `http://host.docker.internal:8888/search` | +| Query params | `q` = `{{ $json.query }}`, `format` = `json`, `categories` = `general` | +| Response | JSON | + +--- + +## 2. Firecrawl — Web Scraper + +**Base URL:** `http://localhost:3002` +**No API key required** (self-hosted, `USE_DB_AUTHENTICATION=false`). + +### Scrape a single URL + +``` +POST /v1/scrape +Content-Type: application/json + +{ + "url": "https://example.com", + "formats": ["markdown"] +} +``` + +**Example curl:** +```bash +curl -X POST http://localhost:3002/v1/scrape \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com", "formats": ["markdown"]}' +``` + +**Response shape:** +```json +{ + "success": true, + "data": { + "markdown": "# Example Domain\n\n...", + "metadata": { + "title": "Example Domain", + "sourceURL": "https://example.com" + } + } +} +``` + +### Crawl a site (async) + +``` +POST /v1/crawl +Content-Type: application/json + +{ + "url": "https://example.com", + "limit": 10, + "scrapeOptions": { "formats": ["markdown"] } +} +``` + +Returns a `jobId`. Poll `GET /v1/crawl/` for results. + +### n8n HTTP Request node (scrape) + +| Field | Value | +|-------|-------| +| Method | POST | +| URL | `http://host.docker.internal:3002/v1/scrape` | +| Body (JSON) | `{"url": "{{ $json.url }}", "formats": ["markdown"]}` | +| Response | JSON | + +### Queue admin UI + +`http://localhost:3002/admin/aitools-bull-changeme/queues` + +--- + +## 3. HHEM API — Hallucination Evaluator + +**Base URL:** `http://localhost:8881` +**Model:** `vectara/hallucination_evaluation_model` (183 M BERT-based, CPU-only) + +> On first start the model is downloaded from HuggingFace (~700 MB). +> Cached in the `hhem-model-cache` volume for subsequent restarts. + +### Score + +``` +POST /score +Content-Type: application/json + +{ + "source": "The Eiffel Tower is in Paris, France.", + "generated": "The Eiffel Tower is located in Paris." +} +``` + +**Response:** +```json +{ + "score": 0.9741, + "label": "grounded" +} +``` + +| Score range | Meaning | +|-------------|---------| +| > 0.5 | **Grounded** — generated text is faithful to source | +| ≤ 0.5 | **Hallucinated** — generated text contradicts or invents facts | + +**Health check:** +```bash +curl http://localhost:8881/health +# {"status":"ok","model":"vectara/hallucination_evaluation_model","loaded":true} +``` + +### n8n HTTP Request node + +| Field | Value | +|-------|-------| +| Method | POST | +| URL | `http://host.docker.internal:8881/score` | +| Body (JSON) | `{"source": "{{ $json.scrapedContent }}", "generated": "{{ $json.llmAnswer }}"}` | +| Response | JSON | + +--- + +## n8n Agentic Pipeline Example + +This stack is designed for a **Search → Scrape → Validate** pipeline: + +``` +1. [HTTP Request] → SearXNG: search for the user's query, extract top URL +2. [HTTP Request] → Firecrawl /v1/scrape: scrape the top result into markdown +3. [LLM Node] → Generate an answer grounded in the scraped content +4. [HTTP Request] → HHEM /score: validate the answer against the scraped source +5. [If] → Route: score > 0.5 → return answer | score ≤ 0.5 → flag/retry +``` + +### n8n expression for step 4 body: +```json +{ + "source": "{{ $('Firecrawl Scrape').item.json.data.markdown }}", + "generated": "{{ $('LLM').item.json.text }}" +} +``` + +--- + +## Build Notes (ARM64 / aarch64) + +- **SearXNG**: official image is multi-arch (ARM64 supported natively). +- **Redis**: `redis:7-alpine` is multi-arch. +- **Firecrawl**: built from source at `/home/sparky/Docker/firecrawl/git/firecrawl/`. + Dockerfile targets Node 22 slim which supports ARM64. +- **HHEM API**: `python:3.11-slim` + PyTorch CPU. PyPI provides `linux_aarch64` wheels for torch 2.x. + +## Rebuilding after Firecrawl updates + +```bash +git -C ~/Docker/firecrawl/git/firecrawl pull +docker compose build firecrawl-playwright firecrawl-api +docker compose up -d firecrawl-playwright firecrawl-api +``` + +## Connecting n8n to this stack + +If n8n does not already have `host.docker.internal` available, add to the n8n service in its compose file: + +```yaml +extra_hosts: + - "host.docker.internal:host-gateway" +``` + +Then use `http://host.docker.internal:` in all n8n HTTP Request nodes. diff --git a/build-images.sh b/build-images.sh new file mode 100755 index 0000000..562f81c --- /dev/null +++ b/build-images.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Run this ONCE before deploying the Portainer stack. +# Builds all custom images and tags them for use without a registry. +set -euo pipefail + +FIRECRAWL_SRC="/home/sparky/Docker/firecrawl/git/firecrawl" +HHEM_SRC="/home/sparky/Docker/searXNG Firecrawl HHEM /hhem-api" + +echo "=== [1/4] Building firecrawl-postgres:local ===" +docker build -t firecrawl-postgres:local "$FIRECRAWL_SRC/apps/nuq-postgres" + +echo "=== [2/4] Building firecrawl-playwright:local ===" +docker build -t firecrawl-playwright:local "$FIRECRAWL_SRC/apps/playwright-service-ts" + +echo "=== [3/4] Building firecrawl-api:local ===" +docker build -t firecrawl-api:local "$FIRECRAWL_SRC/apps/api" + +echo "=== [4/4] Building hhem-api:local ===" +docker build -t hhem-api:local "$HHEM_SRC" + +echo "" +echo "All images built. You can now deploy the Portainer stack." +docker images | grep -E "firecrawl|hhem-api" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5440f46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,178 @@ +name: ai-tools-stack + +# --------------------------------------------------------------------------- +# Shared build env for Firecrawl services (all built from local source) +# --------------------------------------------------------------------------- +x-firecrawl-build: &firecrawl-build + build: + context: /home/sparky/Docker/firecrawl/git/firecrawl/apps/api + dockerfile: Dockerfile + +x-firecrawl-env: &firecrawl-env + USE_DB_AUTHENTICATION: "false" + REDIS_URL: redis://firecrawl-redis:6379 + REDIS_RATE_LIMIT_URL: redis://firecrawl-redis:6379 + PLAYWRIGHT_MICROSERVICE_URL: http://firecrawl-playwright:3000/scrape + NUQ_DATABASE_URL: postgres://postgres:postgres@firecrawl-postgres:5432/postgres + BULL_AUTH_KEY: "aitools-bull-changeme" + HOST: "0.0.0.0" + PORT: "3002" + NODE_ENV: production + # Wire SearXNG so Firecrawl's /search uses it instead of direct Google + SEARXNG_ENDPOINT: http://searxng:8080 + +networks: + ai-tools: + name: ai-tools + driver: bridge + +volumes: + searxng-cache: + firecrawl-redis-data: + firecrawl-pg-data: + hhem-model-cache: + +services: + + # ========================================================================= + # 1. SearXNG — meta-search engine with JSON API + # ========================================================================= + searxng: + image: docker.io/searxng/searxng:latest + container_name: searxng + restart: unless-stopped + ports: + - "8888:8080" + volumes: + - ./searxng:/etc/searxng:rw + - searxng-cache:/var/cache/searxng:rw + environment: + - SEARXNG_BASE_URL=http://localhost:8888/ + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + networks: + - ai-tools + + # ========================================================================= + # 2. Firecrawl — web scraper (self-hosted, no external API key) + # Built from local source at /home/sparky/Docker/firecrawl/git/firecrawl/ + # ========================================================================= + + firecrawl-redis: + image: redis:7-alpine + container_name: firecrawl-redis + restart: unless-stopped + command: redis-server --bind 0.0.0.0 --save 60 1 --loglevel warning + volumes: + - firecrawl-redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - ai-tools + + firecrawl-postgres: + build: + context: /home/sparky/Docker/firecrawl/git/firecrawl/apps/nuq-postgres + dockerfile: Dockerfile + container_name: firecrawl-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + volumes: + - firecrawl-pg-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - ai-tools + + firecrawl-playwright: + build: + context: /home/sparky/Docker/firecrawl/git/firecrawl/apps/playwright-service-ts + dockerfile: Dockerfile + container_name: firecrawl-playwright + restart: unless-stopped + environment: + PORT: 3000 + shm_size: "2gb" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - ai-tools + + firecrawl-api: + <<: *firecrawl-build + container_name: firecrawl-api + restart: unless-stopped + ports: + - "3002:3002" + environment: + <<: *firecrawl-env + command: node dist/src/harness.js --start-docker + extra_hosts: + - "host.docker.internal:host-gateway" + ulimits: + nofile: + soft: 65535 + hard: 65535 + depends_on: + firecrawl-redis: + condition: service_healthy + firecrawl-postgres: + condition: service_healthy + firecrawl-playwright: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3002/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - ai-tools + + # ========================================================================= + # 3. HHEM API — hallucination evaluator (Vectara, CPU-only) + # ========================================================================= + hhem-api: + build: + context: ./hhem-api + dockerfile: Dockerfile + container_name: hhem-api + restart: unless-stopped + ports: + - "8881:8881" + volumes: + # Cache downloaded model weights across restarts + - hhem-model-cache:/root/.cache/huggingface + environment: + - HF_HOME=/root/.cache/huggingface + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8881/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s # model download on first start + networks: + - ai-tools diff --git a/hhem-api/Dockerfile b/hhem-api/Dockerfile new file mode 100644 index 0000000..eede537 --- /dev/null +++ b/hhem-api/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# Install PyTorch CPU-only. +# The PyTorch whl/cpu index only has x86 wheels; on aarch64 we fall back to +# regular PyPI which publishes native aarch64 wheels for torch 2.x. +RUN pip install --no-cache-dir \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + torch + +RUN pip install --no-cache-dir \ + transformers==4.44.2 \ + fastapi==0.115.0 \ + "uvicorn[standard]==0.30.6" \ + pydantic==2.9.2 \ + sentencepiece \ + accelerate + +COPY app.py . + +EXPOSE 8881 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8881", "--workers", "1"] diff --git a/hhem-api/app.py b/hhem-api/app.py new file mode 100644 index 0000000..d56a206 --- /dev/null +++ b/hhem-api/app.py @@ -0,0 +1,76 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +import torch +from transformers import AutoTokenizer, AutoModelForSequenceClassification +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +MODEL_ID = "vectara/hallucination_evaluation_model" + +tokenizer = None +model = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global tokenizer, model + logger.info(f"Loading model {MODEL_ID} ...") + tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) + model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID) + model.eval() + logger.info("Model ready.") + yield + del model + del tokenizer + + +app = FastAPI(title="HHEM API", version="1.0.0", lifespan=lifespan) + + +class ScoreRequest(BaseModel): + source: str + generated: str + + +class ScoreResponse(BaseModel): + score: float + label: str + + +@app.get("/health") +def health(): + return {"status": "ok", "model": MODEL_ID, "loaded": model is not None} + + +@app.post("/score", response_model=ScoreResponse) +def score(req: ScoreRequest): + if model is None or tokenizer is None: + raise HTTPException(status_code=503, detail="Model not loaded yet") + + inputs = tokenizer( + req.source, + req.generated, + return_tensors="pt", + truncation=True, + max_length=512, + padding=True, + ) + + with torch.no_grad(): + logits = model(**inputs).logits + probs = torch.softmax(logits, dim=-1) + + # Resolve which class index maps to "Factually Consistent" + id2label = model.config.id2label # e.g. {0: "Hallucinated", 1: "Factually Consistent"} + factual_idx = next( + (idx for idx, name in id2label.items() if "consistent" in name.lower() or "factual" in name.lower()), + 1, # fallback: assume class 1 is the positive (consistent) class + ) + + factual_score = probs[0][factual_idx].item() + label = "grounded" if factual_score > 0.5 else "hallucinated" + + return ScoreResponse(score=round(factual_score, 4), label=label) diff --git a/searxng/settings.yml b/searxng/settings.yml new file mode 100644 index 0000000..fc36ba3 --- /dev/null +++ b/searxng/settings.yml @@ -0,0 +1,93 @@ +general: + debug: false + instance_name: "SearXNG (local)" + enable_metrics: false + +search: + safe_search: 0 + autocomplete: "" + default_lang: "auto" + ban_time_on_fail: 5 + max_ban_time_on_fail: 120 + + # JSON format is required for agent/API access + formats: + - html + - json + +server: + port: 8080 + bind_address: "0.0.0.0" + base_url: false + # Disable rate limiter so local agents (n8n, Firecrawl) can query freely + limiter: false + public_instance: false + # Change this to any random 32+ character string + secret_key: "n8n-ai-tools-searxng-secret-key-dgx-spark-local-2024" + image_proxy: false + +ui: + static_use_hash: true + default_locale: "" + query_in_title: false + infinite_scroll: false + center_alignment: false + default_theme: simple + theme_args: + simple_style: auto + +# Outgoing HTTP settings — no proxy needed on local network +outgoing: + request_timeout: 15.0 + max_request_timeout: 30.0 + useragent_suffix: "" + pool_connections: 100 + pool_maxsize: 20 + enable_http2: true + +# Enabled engines — good balance of quality and reliability +engines: + - name: bing + engine: bing + shortcut: b + disabled: false + + - name: duckduckgo + engine: duckduckgo + shortcut: d + disabled: false + + - name: google + engine: google + shortcut: g + disabled: false + + - name: brave + engine: brave + shortcut: br + disabled: false + + - name: wikipedia + engine: wikipedia + shortcut: wp + disabled: false + + - name: github + engine: github + shortcut: gh + disabled: true + + - name: arxiv + engine: arxiv + shortcut: ax + disabled: true + +categories_as_tabs: + general: + engines: + - bing + - duckduckgo + - google + - brave + language: all + safe_search: 0