Compare commits
5 Commits
458989e6cd
...
4d78dd4524
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d78dd4524 | ||
|
|
f7001d5130 | ||
|
|
ff2e13a16f | ||
|
|
5783963816 | ||
|
|
837d6e5866 |
73
automation/bin/pd_backup_lib.sh
Executable file
73
automation/bin/pd_backup_lib.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
AUTOMATION_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-$AUTOMATION_DIR/.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Missing env file: $ENV_FILE" >&2
|
||||
echo "Copy automation/.env.example to automation/.env and fill in real values first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
required_vars=(
|
||||
SERENITY_BACKUP_HOST
|
||||
SERENITY_BACKUP_ROOT
|
||||
PD_APPDATA_ROOT
|
||||
PD_DATABASES_ROOT
|
||||
PD_TANK_DOCKER_ROOT
|
||||
PD_DB_DUMP_ROOT
|
||||
POSTGRES_CONTAINER
|
||||
)
|
||||
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
echo "Required variable $var is empty in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$PD_DB_DUMP_ROOT"
|
||||
|
||||
DOCKER_BIN="${DOCKER_BIN:-/usr/bin/docker}"
|
||||
SUDO_BIN="${SUDO_BIN:-/usr/bin/sudo}"
|
||||
USE_SUDO_FOR_DOCKER="${USE_SUDO_FOR_DOCKER:-true}"
|
||||
BACKUP_KNOWN_HOSTS="${BACKUP_KNOWN_HOSTS:-${HOME:-/root}/.ssh/known_hosts}"
|
||||
|
||||
if [[ ! -x "$DOCKER_BIN" ]]; then
|
||||
echo "Docker binary not executable: $DOCKER_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker_cmd() {
|
||||
if [[ "${EUID:-$(id -u)}" == "0" ]]; then
|
||||
"$DOCKER_BIN" "$@"
|
||||
elif [[ "$USE_SUDO_FOR_DOCKER" == "true" ]]; then
|
||||
"$SUDO_BIN" -n "$DOCKER_BIN" "$@"
|
||||
else
|
||||
"$DOCKER_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
timestamp_utc() {
|
||||
date -u +"%Y%m%dT%H%M%SZ"
|
||||
}
|
||||
|
||||
ssh_args() {
|
||||
local opts="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$BACKUP_KNOWN_HOSTS"
|
||||
if [[ -n "${BACKUP_SSH_KEY:-}" ]]; then
|
||||
printf '%s' "-i $BACKUP_SSH_KEY $opts"
|
||||
else
|
||||
printf '%s' "$opts"
|
||||
fi
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
printf 'ssh %s' "$(ssh_args)"
|
||||
}
|
||||
22
automation/bin/pd_backup_postgres.sh
Executable file
22
automation/bin/pd_backup_postgres.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||
|
||||
stamp="$(timestamp_utc)"
|
||||
dbs="${POSTGRES_DB_LIST:-}"
|
||||
|
||||
if [[ -z "$dbs" ]]; then
|
||||
echo "POSTGRES_DB_LIST is empty; nothing to dump." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for db in $dbs; do
|
||||
out="$PD_DB_DUMP_ROOT/${db}_${stamp}.sql.gz"
|
||||
echo "Dumping Postgres database '$db' to $out"
|
||||
docker_cmd exec "$POSTGRES_CONTAINER" pg_dump -U postgres "$db" | gzip -9 > "$out"
|
||||
done
|
||||
|
||||
echo "Postgres dumps complete: $PD_DB_DUMP_ROOT"
|
||||
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable file
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||
|
||||
ssh_opts="$(ssh_args)"
|
||||
remote_root="$SERENITY_BACKUP_HOST:$SERENITY_BACKUP_ROOT"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
ssh $ssh_opts "$SERENITY_BACKUP_HOST" mkdir -p \
|
||||
"$SERENITY_BACKUP_ROOT/appdata" \
|
||||
"$SERENITY_BACKUP_ROOT/databases" \
|
||||
"$SERENITY_BACKUP_ROOT/tank-docker" \
|
||||
"$SERENITY_BACKUP_ROOT/db-dumps"
|
||||
|
||||
rsync_cmd=(
|
||||
rsync -a --delete
|
||||
--exclude '*.sqlite-journal'
|
||||
--exclude '*.db-journal'
|
||||
--exclude '*.sqlite-wal'
|
||||
--exclude '*.db-wal'
|
||||
--exclude '*.tmp'
|
||||
)
|
||||
rsync_cmd+=( -e "$(ssh_cmd)" )
|
||||
|
||||
run_rsync() {
|
||||
local label="$1"
|
||||
shift
|
||||
echo "$label"
|
||||
set +e
|
||||
"${rsync_cmd[@]}" "$@"
|
||||
local rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 24 ]]; then
|
||||
echo "rsync completed with vanished-file warning (code 24); treating as acceptable for live appdata."
|
||||
return 0
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
run_rsync "Syncing appdata to Serenity" "$PD_APPDATA_ROOT/" "$remote_root/appdata/"
|
||||
|
||||
run_rsync "Syncing database volume roots to Serenity" "$PD_DATABASES_ROOT/" "$remote_root/databases/"
|
||||
|
||||
run_rsync "Syncing selected tank docker data to Serenity" "$PD_TANK_DOCKER_ROOT/" "$remote_root/tank-docker/"
|
||||
|
||||
run_rsync "Syncing DB dumps to Serenity" "$PD_DB_DUMP_ROOT/" "$remote_root/db-dumps/"
|
||||
|
||||
echo "Serenity sync complete: $remote_root"
|
||||
9
automation/bin/run_pd_backups.sh
Executable file
9
automation/bin/run_pd_backups.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
"$SCRIPT_DIR/pd_backup_postgres.sh"
|
||||
"$SCRIPT_DIR/pd_backup_sync_to_serenity.sh"
|
||||
|
||||
echo "PD backup run complete."
|
||||
@@ -26,6 +26,16 @@ Back up at minimum:
|
||||
|
||||
Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
|
||||
|
||||
Repo-side scaffolding now lives under `automation/`:
|
||||
|
||||
- `automation/bin/pd_backup_postgres.sh`
|
||||
- `automation/bin/pd_backup_sync_to_serenity.sh`
|
||||
- `automation/bin/run_pd_backups.sh`
|
||||
|
||||
These are staged helper scripts only until they are deployed with a real `.env`, SSH trust to Serenity, and a live scheduler on PD.
|
||||
|
||||
Recommended live deployment model on PD: a plain **root cron** job invoking `/usr/bin/bash bin/run_pd_backups.sh` from `/mnt/docker-ssd/docker/compose/automation`.
|
||||
|
||||
## .env File Backup Options
|
||||
1. **Private Gitea repo** — short term, push all .env files to a private repo on your local Gitea
|
||||
2. **Encrypted restic** — include .env files in restic backup with encryption, push offsite (Backblaze B2)
|
||||
|
||||
85
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal file
85
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# PD Backup Deployment
|
||||
|
||||
Deploy the PD → Serenity backup runner from the real compose path on PD:
|
||||
|
||||
```bash
|
||||
/mnt/docker-ssd/docker/compose/automation
|
||||
```
|
||||
|
||||
## Why this model
|
||||
|
||||
- PD is the compose/data host, so backups should originate there.
|
||||
- TrueNAS SCALE is happier with plain cron + shell than extra appliance-fighting service glue.
|
||||
- Root cron on PD avoids the common `sudo`/Docker socket permission mess.
|
||||
- Backups should still run even if n8n is down.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Repo content present on PD at `/mnt/docker-ssd/docker/compose/automation`
|
||||
2. Writable dump directory:
|
||||
```bash
|
||||
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||
```
|
||||
3. SSH trust from PD to Serenity for the selected account
|
||||
4. Real `.env` created from `.env.example`
|
||||
5. Preferred: install the cron job in root's crontab on PD
|
||||
6. If using a non-root PD account instead, `sudo -n /usr/bin/docker` must work
|
||||
|
||||
## Suggested `.env` values to review
|
||||
|
||||
```dotenv
|
||||
SERENITY_BACKUP_HOST=root@10.5.1.5
|
||||
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
|
||||
PD_APPDATA_ROOT=/mnt/docker-ssd/docker/appdata
|
||||
PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
|
||||
PD_TANK_DOCKER_ROOT=/mnt/tank/docker
|
||||
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
|
||||
POSTGRES_CONTAINER=shared-postgres
|
||||
POSTGRES_DB_LIST="n8n paperless"
|
||||
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
|
||||
DOCKER_BIN=/usr/bin/docker
|
||||
SUDO_BIN=/usr/bin/sudo
|
||||
USE_SUDO_FOR_DOCKER=true
|
||||
```
|
||||
|
||||
## Manual first run
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/automation
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||
/usr/bin/bash bin/run_pd_backups.sh
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- fresh `*.sql.gz` files appear in `/mnt/tank/docker/backups/db-dumps`
|
||||
- appdata/database/tank-docker content lands under the chosen Serenity backup root
|
||||
- no sudo prompt appeared during `docker exec`
|
||||
|
||||
## Cron install
|
||||
|
||||
Preferred: install in **root's crontab** on PD.
|
||||
|
||||
```cron
|
||||
# BEGIN PD BACKUPS
|
||||
15 2 * * * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_backups.sh >> /mnt/tank/docker/backups/pd-backups.log 2>&1
|
||||
# END PD BACKUPS
|
||||
```
|
||||
|
||||
## Post-deploy checks
|
||||
|
||||
```bash
|
||||
tail -100 /mnt/tank/docker/backups/pd-backups.log
|
||||
ls -lh /mnt/tank/docker/backups/db-dumps
|
||||
ssh root@10.5.1.5 'find /mnt/user/backups/plausible-deniability -maxdepth 2 -type d | sort | head -40'
|
||||
```
|
||||
|
||||
## Important limitations
|
||||
|
||||
- Do **not** use `systemctl restart docker` on PD.
|
||||
- Keep source/destination paths under `/mnt/...`.
|
||||
- `rsync --delete` is intentional; use a dedicated destination root, not a shared miscellaneous folder.
|
||||
- This scaffolding does not yet back up `.env` files here because those are already handled by the separate encrypted/private-repo process.
|
||||
- If you insist on a non-root cron user on PD, you will need passwordless Docker access (`sudo -n /usr/bin/docker ...`) and write access to the chosen dump directory.
|
||||
@@ -19,6 +19,16 @@ Each stack contains:
|
||||
- `.env.example` — committed with placeholder values
|
||||
- optional init scripts (e.g. `initdb/`, `initdb-mariadb/`)
|
||||
|
||||
## Host-Run Services
|
||||
If something on NOMAD is not deployed as a Docker compose stack, its live runtime belongs under `/opt/<service>`.
|
||||
|
||||
Examples:
|
||||
- `/opt/project-nomad`
|
||||
- `/opt/newt`
|
||||
- `/opt/doris-dashboard`
|
||||
|
||||
Use the repo for source/staging, but do not run production host services from an agent workspace or random checkout under `/home/fizzlepoof/.openclaw/workspace`.
|
||||
|
||||
## Deployment Order
|
||||
1. `databases/` — must be up before anything else
|
||||
2. `infrastructure/` — newt creates the pangolin network
|
||||
|
||||
@@ -1,91 +1,125 @@
|
||||
# Kima-Hub Deployment Plan
|
||||
# Kima-Hub Deployment / Repair Reference
|
||||
|
||||
**Host:** PlausibleDeniability (PD)
|
||||
**Port:** 3333
|
||||
**Stack dir:** `/mnt/docker-ssd/docker/compose/media-extra/`
|
||||
**Status:** Pending
|
||||
**Stack dir in repo:** `media/kima-hub/`
|
||||
**Live stack dir on host:** `/mnt/docker-ssd/docker/compose/media/kima-hub/`
|
||||
**Status:** Repo stack corrected to match upstream all-in-one Kima layout and live service verified healthy on PD
|
||||
|
||||
## What It Is
|
||||
## Current canonical stack
|
||||
|
||||
Kima-Hub is a self-hosted music streaming platform — think personal Spotify. Point it at your music library and it handles cataloging, artist discovery, AI-powered playlists, podcast subscriptions, and seamless integration with Lidarr and Audiobookshelf.
|
||||
The upstream all-in-one Kima image expects this shape:
|
||||
|
||||
## Pre-Deploy Checklist
|
||||
- **Image:** `ghcr.io/chevron7locked/kima:latest`
|
||||
- **Container name:** `kima`
|
||||
- **Host port:** `3333`
|
||||
- **Container port:** `3030`
|
||||
- **Persistent data:**
|
||||
- `/mnt/docker-ssd/docker/appdata/kima-hub/data:/data`
|
||||
- **Media mount:**
|
||||
- `/mnt/unraid/data/media/music:/music`
|
||||
- **Network:** `pangolin`
|
||||
- **Host gateway alias:** `host.docker.internal:host-gateway`
|
||||
|
||||
- [ ] Confirm music library path on Unraid NFS (`/mnt/unraid/data/media/music` or similar)
|
||||
- [ ] Create appdata directory: `sudo mkdir -p /mnt/tank/docker/appdata/kima-hub`
|
||||
- [ ] Create stack directory: `sudo mkdir -p /mnt/docker-ssd/docker/compose/media-extra`
|
||||
## Canonical live `.env` shape
|
||||
|
||||
## docker-compose.yaml
|
||||
```env
|
||||
TZ=America/New_York
|
||||
SESSION_SECRET=
|
||||
SETTINGS_ENCRYPTION_KEY=
|
||||
KIMA_CALLBACK_URL=http://host.docker.internal:3333
|
||||
DISABLE_CLAP=
|
||||
LIDARR_API_KEY=
|
||||
FANART_API_KEY=
|
||||
LASTFM_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
SOULSEEK_USERNAME=
|
||||
SOULSEEK_PASSWORD=
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `SESSION_SECRET` and `SETTINGS_ENCRYPTION_KEY` can be left blank; current upstream all-in-one image will generate and persist them under `/data/secrets` on first start.
|
||||
- `DISABLE_CLAP=true` is the low-memory / no-AI-vibe fallback if the analyzer proves too heavy.
|
||||
- `KIMA_CALLBACK_URL` should point at the host-facing Kima URL Lidarr can reach.
|
||||
|
||||
## Canonical compose file
|
||||
|
||||
```yaml
|
||||
services:
|
||||
kima-hub:
|
||||
image: chevron7locked/kima:latest
|
||||
container_name: kima-hub
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3333:3030"
|
||||
volumes:
|
||||
- /mnt/tank/docker/appdata/kima-hub:/data
|
||||
- /mnt/unraid/data/media/music:/music:ro
|
||||
kima:
|
||||
image: ghcr.io/chevron7locked/kima:latest
|
||||
container_name: kima
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q 0BD6 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
|
||||
- DISABLE_CLAP=${DISABLE_CLAP:-}
|
||||
- LIDARR_API_KEY=${LIDARR_API_KEY:-}
|
||||
- FANART_API_KEY=${FANART_API_KEY:-}
|
||||
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- SOULSEEK_USERNAME=${SOULSEEK_USERNAME:-}
|
||||
- SOULSEEK_PASSWORD=${SOULSEEK_PASSWORD:-}
|
||||
volumes:
|
||||
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
|
||||
- /mnt/unraid/data/media/music:/music
|
||||
ports:
|
||||
- "3333:3030"
|
||||
networks:
|
||||
- pangolin
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
pangolin:
|
||||
external: true
|
||||
```
|
||||
|
||||
> **Note:** Internal port is `3030`, mapped to `3333` on the host. Hex `0BD6` = port 3030 for healthcheck.
|
||||
> **Music path:** Adjust `/mnt/unraid/data/media/music` to match your actual Unraid music library path.
|
||||
> **Read-only mount:** Music library is mounted `:ro` — Kima-Hub should not need write access to source files.
|
||||
|
||||
## .env.example
|
||||
|
||||
```env
|
||||
TZ=America/New_York
|
||||
```
|
||||
|
||||
## Deploy
|
||||
## Validate before deploy
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/media-extra
|
||||
sudo docker compose --env-file .env config # validate
|
||||
sudo docker compose --env-file .env up -d
|
||||
sudo docker logs kima-hub --tail 30
|
||||
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||
sudo docker compose --env-file .env config
|
||||
```
|
||||
|
||||
## Post-Deploy
|
||||
## Deploy / repair
|
||||
|
||||
1. Open `http://10.5.1.6:3333` and create your account
|
||||
2. Point Kima at your music directory (`/music`)
|
||||
3. In Lidarr: Settings → Connect → add Kima-Hub webhook
|
||||
4. In Audiobookshelf: verify Kima can reach it at `http://10.5.1.6:13358`
|
||||
5. Add Pangolin resource: `kima.paccoco.com` → port 3333
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||
sudo docker compose --env-file .env up -d
|
||||
sudo docker compose --env-file .env ps
|
||||
sudo docker logs --tail 60 kima
|
||||
```
|
||||
|
||||
## Pangolin Resource
|
||||
## Post-deploy checks
|
||||
|
||||
Add in Pangolin dashboard:
|
||||
- **Subdomain:** `kima.paccoco.com`
|
||||
- **Target:** `http://10.5.1.6:3333`
|
||||
- **Auth:** Enable (personal use only)
|
||||
1. Open `http://10.5.1.6:3333`
|
||||
2. Confirm the app starts and initial setup loads
|
||||
3. Confirm `/music` is visible to Kima
|
||||
4. Confirm Kima creates and uses `/data` persistence correctly
|
||||
5. Add Pangolin resource for `kima.paccoco.com` if desired
|
||||
6. Confirm the NFS-backed Serenity media mount at `/mnt/unraid/data/media` is present on PD and contains the music library Kima should scan
|
||||
|
||||
## Storage Decision
|
||||
Verified on 2026-05-15:
|
||||
- container `kima` running and healthy
|
||||
- `http://10.5.1.6:3333/api/health` returned status OK
|
||||
- live mount narrowed to `/mnt/unraid/data/media/music:/music`
|
||||
|
||||
| Path | Tier | Reason |
|
||||
|------|------|--------|
|
||||
| `/mnt/tank/docker/appdata/kima-hub` | tank | Config, DB, playlists — not write-heavy |
|
||||
| `/mnt/unraid/data/media/music` | Unraid NFS (read-only) | Source library stays on Serenity |
|
||||
## Optional integrations / secrets
|
||||
|
||||
Not required just to boot:
|
||||
|
||||
- **Lidarr API key** — for Lidarr integration
|
||||
- **Fanart API key** — for artist images
|
||||
- **Last.fm API key** — for enrichment/recommendations
|
||||
- **OpenAI API key** — optional AI features documented upstream; current upstream docs only explicitly mention OpenAI for that part
|
||||
- **Soulseek username/password** — if Soulseek features are used
|
||||
|
||||
Do **not** commit live secrets into the repo copy of `.env`.
|
||||
|
||||
## Notes
|
||||
|
||||
- NFS mount must be up before starting (`sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh`)
|
||||
- If you transcode on the fly, CPU load on PD will spike; Kima-Hub is CPU-bound for transcoding
|
||||
- Kima-Hub has no GPU acceleration — pure CPU transcoding
|
||||
- The previous repo/live shape using `/app/config`, `/app/data`, `PUID`, `PGID`, `KIMA_MUSIC_PATH`, and `3333:3333` did not match current upstream all-in-one docs.
|
||||
- KitchenOwl already lives in the `home/` stack; Kima lives in its own `media/kima-hub/` stack.
|
||||
- Keep compose-managed runtime under `/mnt/docker-ssd/docker/compose/...` per SOP.
|
||||
|
||||
@@ -185,10 +185,11 @@ These are expected to be managed by Wings/Pelican rather than a local compose fi
|
||||
- Project N.O.M.A.D.-managed app containers stay exactly as-is and are **hands-off** unless John explicitly says otherwise.
|
||||
- `whisper` and `node-exporter` are not protected services; they can be removed, but their exact runtime has been documented above for rebuild/recovery.
|
||||
- Pelican/Wings-managed game containers are part of the permanent game-server stack on NOMAD and stay in place.
|
||||
- Doris Dashboard source now lives in the canonical homelab repo at `/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard` rather than the old workspace-only repo.
|
||||
- Doris Dashboard source belongs in the homelab repo, and the live NOMAD runtime belongs at `/opt/doris-dashboard`.
|
||||
|
||||
### Doris Dashboard (local NOMAD homepage)
|
||||
- Canonical repo path: `/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard`
|
||||
- Live runtime path: `/opt/doris-dashboard`
|
||||
- Repo source path: `/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard`
|
||||
- Purpose: local-only dashboard for briefing/news/weather/homelab pulse/Paperless review on NOMAD
|
||||
- Generator: `home/doris-dashboard/bin/generate_dashboard.py`
|
||||
- Interactive LAN server: `home/doris-dashboard/bin/dashboard_server.py`
|
||||
@@ -196,7 +197,12 @@ These are expected to be managed by Wings/Pelican rather than a local compose fi
|
||||
- Runtime notes:
|
||||
- Generated output and queue snapshots are runtime data; source/config lives in git, generated JSON/HTML does not need to be treated as canonical.
|
||||
- Topic thumbs write back to `doris-digest/data/feedback.json`.
|
||||
- Paperless acknowledge/dismiss state writes to `home/doris-dashboard/data/paperless_review_state.json` on the local checkout.
|
||||
- Paperless acknowledge/dismiss state writes to `/opt/doris-dashboard/data/paperless_review_state.json` on the live host.
|
||||
|
||||
### Host-run service path rule
|
||||
- Docker/compose-managed stacks live under `/mnt/docker-ssd/docker/compose`.
|
||||
- Host-run services that are not compose stacks live under `/opt/<service>`.
|
||||
- Do not run production services from any agent workspace or ad-hoc checkout under `/home/fizzlepoof/.openclaw/workspace`.
|
||||
|
||||
### Safe to remove / how to restore
|
||||
#### Safe to remove now
|
||||
|
||||
@@ -2,7 +2,7 @@ name: documents
|
||||
|
||||
services:
|
||||
paperless:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.16
|
||||
container_name: paperless
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
- pangolin
|
||||
|
||||
tika:
|
||||
image: apache/tika:latest
|
||||
image: apache/tika:3.1.0.0
|
||||
container_name: paperless-tika
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
- paperless-internal
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:latest
|
||||
image: gotenberg/gotenberg:8.17
|
||||
container_name: paperless-gotenberg
|
||||
restart: unless-stopped
|
||||
mem_limit: 256m
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
Local static homepage for John’s morning briefing and peek-anytime news dashboard.
|
||||
|
||||
Canonical path:
|
||||
Live runtime path:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
/opt/doris-dashboard
|
||||
```
|
||||
|
||||
Source path in the homelab repo:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard
|
||||
```
|
||||
|
||||
Generated page:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html
|
||||
/opt/doris-dashboard/public/index.html
|
||||
```
|
||||
|
||||
## What it shows
|
||||
@@ -29,14 +35,14 @@ Generated page:
|
||||
## Generate manually
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
cd /opt/doris-dashboard
|
||||
bin/generate_dashboard.py
|
||||
```
|
||||
|
||||
## Preview locally
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public
|
||||
cd /opt/doris-dashboard/public
|
||||
python3 -m http.server 8787
|
||||
```
|
||||
|
||||
@@ -54,9 +60,10 @@ Installed in `fizzlepoof`’s crontab:
|
||||
|
||||
```cron
|
||||
# BEGIN DORIS DASHBOARD
|
||||
DORIS_DASHBOARD_DIR=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
# Refresh local dashboard after digest collection windows.
|
||||
5,35 6-23 * * * cd $DORIS_DASHBOARD_DIR && bin/generate_dashboard.py >> logs/generate.log 2>&1
|
||||
DORIS_DASHBOARD_DIR=/opt/doris-dashboard
|
||||
DORIS_DIGEST_DIR=/home/fizzlepoof/.openclaw/workspace/doris-digest
|
||||
# Refresh local dashboard after digest collection windows; wrapper enforces Chicago daytime.
|
||||
5,35 * * * * cd $DORIS_DASHBOARD_DIR && bin/cron_generate_dashboard.sh >> logs/generate.log 2>&1
|
||||
# END DORIS DASHBOARD
|
||||
```
|
||||
|
||||
@@ -139,7 +146,7 @@ Added sections/data:
|
||||
The generated page still lives at:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html
|
||||
/opt/doris-dashboard/public/index.html
|
||||
```
|
||||
|
||||
And the LAN service remains:
|
||||
@@ -151,7 +158,7 @@ http://10.5.1.16:8787/
|
||||
Verification commands:
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
cd /opt/doris-dashboard
|
||||
python3 -m py_compile bin/generate_dashboard.py
|
||||
bin/generate_dashboard.py
|
||||
curl -fsS http://127.0.0.1:8787/ | grep 'Doris Dashboard'
|
||||
@@ -190,7 +197,7 @@ Implementation notes:
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
cd /opt/doris-dashboard
|
||||
bin/generate_dashboard.py
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
@@ -219,6 +226,16 @@ Important note:
|
||||
|
||||
- The interactive actions require the dashboard to be served by `bin/dashboard_server.py`, not plain `python -m http.server`.
|
||||
|
||||
## Runtime path sanity
|
||||
|
||||
The live systemd unit and crontab must point only at:
|
||||
|
||||
```bash
|
||||
/opt/doris-dashboard
|
||||
```
|
||||
|
||||
Do not run the dashboard from an agent workspace. Agent workspaces are for editing and staging; NOMAD host-run services belong under `/opt/<service>`.
|
||||
|
||||
## Cron fix — 2026-05-13
|
||||
|
||||
The dashboard cron now uses `bin/cron_generate_dashboard.sh`, which checks America/Chicago daytime internally. This avoids relying on `CRON_TZ`, which NOMAD's cron did not honor consistently.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
export DORIS_DASHBOARD_DIR="${DORIS_DASHBOARD_DIR:-$(pwd)}"
|
||||
export DORIS_DIGEST_DIR="${DORIS_DIGEST_DIR:-/home/fizzlepoof/.openclaw/workspace/doris-digest}"
|
||||
|
||||
hour="$(TZ=America/Chicago date +%H)"
|
||||
if (( 10#$hour < 6 || 10#$hour > 23 )); then exit 0; fi
|
||||
bin/generate_dashboard.py
|
||||
|
||||
@@ -7,9 +7,11 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=fizzlepoof
|
||||
Group=fizzlepoof
|
||||
WorkingDirectory=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
ExecStartPre=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/generate_dashboard.py
|
||||
ExecStart=/usr/bin/python3 /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/dashboard_server.py
|
||||
Environment=DORIS_DASHBOARD_DIR=/opt/doris-dashboard
|
||||
Environment=DORIS_DIGEST_DIR=/home/fizzlepoof/.openclaw/workspace/doris-digest
|
||||
WorkingDirectory=/opt/doris-dashboard
|
||||
ExecStartPre=/opt/doris-dashboard/bin/generate_dashboard.py
|
||||
ExecStart=/usr/bin/python3 /opt/doris-dashboard/bin/dashboard_server.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
services:
|
||||
kima-hub:
|
||||
kima:
|
||||
image: ghcr.io/chevron7locked/kima:latest
|
||||
container_name: kima
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- KIMA_MUSIC_PATH=/music
|
||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
|
||||
- DISABLE_CLAP=${DISABLE_CLAP:-}
|
||||
- LIDARR_API_KEY=${LIDARR_API_KEY:-}
|
||||
- FANART_API_KEY=${FANART_API_KEY:-}
|
||||
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- SOULSEEK_USERNAME=${SOULSEEK_USERNAME:-}
|
||||
- SOULSEEK_PASSWORD=${SOULSEEK_PASSWORD:-}
|
||||
volumes:
|
||||
- /mnt/docker-ssd/docker/appdata/kima-hub/config:/app/config
|
||||
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/app/data
|
||||
- /mnt/unraid/data/media:/music
|
||||
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
|
||||
- /mnt/unraid/data/media/music:/music
|
||||
ports:
|
||||
- "3333:3333"
|
||||
- "3333:3030"
|
||||
networks:
|
||||
- pangolin
|
||||
extra_hosts:
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
||||
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -125,7 +125,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
||||
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = 'https://paperless.paccoco.com';\nconst PAPERLESS_TOKEN = '4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d';\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = {\n method,\n url,\n headers,\n json: true,\n };\n\n if (body !== undefined) {\n options.body = body;\n }\n\n return await this.helpers.httpRequest(options);\n}\n\n// Fetch all existing tags. Handles pagination.\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\n\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n\n if (page.next) {\n tagsUrl = page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`;\n } else {\n tagsUrl = null;\n }\n}\n\nconst tagsByName = new Map(\n existingTags.map(tag => [normalizeTagName(tag.name), tag])\n);\n\nconst appliedTags = [];\n\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, {\n name: tagName,\n });\n tagsByName.set(tagName, tag);\n }\n\n if (tag?.id) {\n appliedTags.push(tag);\n }\n}\n\n// Fetch current document so we merge tags instead of overwriting existing ones.\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, {\n tags: mergedTagIds,\n});\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
||||
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = ($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '');\nconst PAPERLESS_TOKEN = $env.PAPERLESS_API_TOKEN;\nif (!PAPERLESS_TOKEN) throw new Error('PAPERLESS_API_TOKEN is not set.');\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = { method, url, headers, json: true };\n if (body !== undefined) options.body = body;\n return await this.helpers.httpRequest(options);\n}\n\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n tagsUrl = page.next ? (page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`) : null;\n}\n\nconst tagsByName = new Map(existingTags.map(tag => [normalizeTagName(tag.name), tag]));\nconst appliedTags = [];\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, { name: tagName });\n tagsByName.set(tagName, tag);\n }\n if (tag?.id) appliedTags.push(tag);\n}\n\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, { tags: mergedTagIds });\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
||||
},
|
||||
"id": "b0d5423b-dc83-4adf-98c6-d95afa3e5c1b",
|
||||
"name": "Apply Suggested Tags to Paperless",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
||||
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -125,7 +125,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
||||
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = 'https://paperless.paccoco.com';\nconst PAPERLESS_TOKEN = '4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d';\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = {\n method,\n url,\n headers,\n json: true,\n };\n\n if (body !== undefined) {\n options.body = body;\n }\n\n return await this.helpers.httpRequest(options);\n}\n\n// Fetch all existing tags. Handles pagination.\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\n\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n\n if (page.next) {\n tagsUrl = page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`;\n } else {\n tagsUrl = null;\n }\n}\n\nconst tagsByName = new Map(\n existingTags.map(tag => [normalizeTagName(tag.name), tag])\n);\n\nconst appliedTags = [];\n\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, {\n name: tagName,\n });\n tagsByName.set(tagName, tag);\n }\n\n if (tag?.id) {\n appliedTags.push(tag);\n }\n}\n\n// Fetch current document so we merge tags instead of overwriting existing ones.\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, {\n tags: mergedTagIds,\n});\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
||||
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = ($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '');\nconst PAPERLESS_TOKEN = $env.PAPERLESS_API_TOKEN;\nif (!PAPERLESS_TOKEN) throw new Error('PAPERLESS_API_TOKEN is not set.');\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = { method, url, headers, json: true };\n if (body !== undefined) options.body = body;\n return await this.helpers.httpRequest(options);\n}\n\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n tagsUrl = page.next ? (page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`) : null;\n}\n\nconst tagsByName = new Map(existingTags.map(tag => [normalizeTagName(tag.name), tag]));\nconst appliedTags = [];\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, { name: tagName });\n tagsByName.set(tagName, tag);\n }\n if (tag?.id) appliedTags.push(tag);\n}\n\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, { tags: mergedTagIds });\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
||||
},
|
||||
"id": "b0d5423b-dc83-4adf-98c6-d95afa3e5c1b",
|
||||
"name": "Apply Suggested Tags to Paperless",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "\nconst body = $json.body || $json;\nlet id = body.document_id || body.id || body.doc_id || body.pk || body.document;\nif (!id || (typeof id === 'string' && id.includes('{'))) {\n id = null;\n}\nreturn [{ json: { document_id: id ? String(id) : null, received_at: new Date().toISOString(), webhook_payload_keys: Object.keys(body) } }];\n"
|
||||
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: String(documentId), received_at: new Date().toISOString(), webhook_payload_keys: Object.keys(body) } }];\n"
|
||||
},
|
||||
"id": "extract-doc-id",
|
||||
"name": "Extract Document ID",
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: $env.PAPERLESS_TRIAGE_MODEL || 'gpt-4o-mini', temperature: 0.1, response_format: { type: 'json_object' }, messages: $json.llm_messages }) }}",
|
||||
"jsonBody": "={{ JSON.stringify({ model: $env.PAPERLESS_TRIAGE_MODEL || 'medium', temperature: 0.1, response_format: { type: 'json_object' }, messages: $json.llm_messages }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "\n// Requires n8n Code node filesystem access: NODE_FUNCTION_ALLOW_BUILTIN=fs,path\nconst fs = require('fs');\nconst path = require('path');\nconst queuePath = $env.PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH || '/data/paperless_review.json';\nconst maxItems = Number($env.PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS || 200);\nfs.mkdirSync(path.dirname(queuePath), { recursive: true });\nlet queue = [];\ntry { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch (_) { queue = []; }\nif (!Array.isArray(queue)) queue = [];\nconst item = $json.review_item;\nqueue = [item, ...queue.filter(x => String(x.id) !== String(item.id))].slice(0, maxItems);\nconst tmp = queuePath + '.tmp';\nfs.writeFileSync(tmp, JSON.stringify(queue, null, 2) + '\n', 'utf8');\nfs.renameSync(tmp, queuePath);\nreturn [{ json: { ...$json, queue_path: queuePath, queue_count: queue.length } }];\n"
|
||||
"jsCode": "\n// Requires n8n Code node filesystem access: NODE_FUNCTION_ALLOW_BUILTIN=fs,path\nconst fs = require('fs');\nconst path = require('path');\nconst queuePath = $env.PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH || '/data/paperless_review.json';\nconst maxItems = Number($env.PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS || 200);\nfs.mkdirSync(path.dirname(queuePath), { recursive: true });\nlet queue = [];\ntry { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch (_) { queue = []; }\nif (!Array.isArray(queue)) queue = [];\nconst item = $json.review_item;\nqueue = [item, ...queue.filter(x => String(x.id) !== String(item.id))].slice(0, maxItems);\nconst tmp = queuePath + '.tmp';\nfs.writeFileSync(tmp, JSON.stringify(queue, null, 2) + '\\n', 'utf8');\nfs.renameSync(tmp, queuePath);\nreturn [{ json: { ...$json, queue_path: queuePath, queue_count: queue.length } }];\n"
|
||||
},
|
||||
"id": "write-queue",
|
||||
"name": "Write Doris Review Queue JSON",
|
||||
@@ -313,11 +313,6 @@
|
||||
"node": "Apply Safe Updates Enabled?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Urgent Notification Configured?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || input;\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document\n].filter(v => v !== undefined && v !== null);\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
||||
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
||||
},
|
||||
"id": "normalize-paperless-webhook",
|
||||
"name": "Normalize Paperless Webhook",
|
||||
@@ -152,13 +152,13 @@
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": {{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"={{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "school-ai-summary",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || input;\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document\n].filter(v => v !== undefined && v !== null);\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
||||
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
||||
},
|
||||
"id": "normalize-paperless-webhook",
|
||||
"name": "Normalize Paperless Webhook",
|
||||
@@ -152,13 +152,13 @@
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": {{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"={{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "school-ai-summary",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| 14 | Paperless Inbox Reminder | Scheduled (Monday 9 AM) | Queries Paperless for untagged documents; sends Gotify reminder with oldest items listed, or a ✅ clear if inbox is empty |
|
||||
| 15 | n8n Self-Health Monitor | Scheduled (every 15 min) | Checks n8n execution history for failures in the past hour; deduplicates via Postgres; pushes Gotify alert with workflow name and error summary |
|
||||
| 16 | NFS Mount Watchdog | Scheduled (every 5 min) | Probes NFS mount points on PD; if missing: runs mount script, re-probes, restarts affected containers (Plex, Audiobookshelf, Immich); notifies via Gotify on heal or escalation |
|
||||
| 17 | Paperless Intake Triage → Doris Review Queue | `POST /webhook/paperless/intake-triage` | Fetches a Paperless document by `document_id`, runs conservative AI triage, writes a Doris Dashboard review queue JSON file, and keeps safe auto-apply disabled unless explicitly enabled |
|
||||
| 18 | Telegram School Intake → Postgres → Paperless v1.2 | `POST /webhook/school/intake/upload` | Accepts multipart schoolwork uploads plus class/assignment metadata, uploads into Paperless with deterministic `intake_id` in the filename/title, and returns the resulting task/document context |
|
||||
| 19 | Paperless School Intake Metadata Enrichment v1.1 | `POST /webhook/school/intake/paperless-enrich` | Handles Paperless post-consumption webhooks, resolves metadata from deterministic filename/title, then applies deterministic title/tags and optional correspondent mapping |
|
||||
|
||||
@@ -79,6 +80,10 @@ Two steps required — both the `.env` file AND the `docker-compose.yaml` `envir
|
||||
| `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON` | 19 | JSON object mapping submission kind → Paperless tag ID |
|
||||
| `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON` | 19 | JSON object mapping paper kind → Paperless document type ID |
|
||||
| `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` | 19 | JSON object mapping class name → Paperless correspondent ID |
|
||||
| `PAPERLESS_TRIAGE_MODEL` | 17 | Optional LiteLLM model override; defaults to `medium` |
|
||||
| `PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH` | 17 | Writable queue path inside the n8n container, e.g. `/data/paperless-triage/paperless_review.json` |
|
||||
| `PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS` | 17 | Optional queue size cap; defaults to `200` |
|
||||
| `PAPERLESS_TRIAGE_APPLY_SAFE` | 17 | Keep `false` unless you intentionally want title-only safe auto-apply enabled |
|
||||
| `N8N_API_KEY` | 15 | n8n → Settings → API → Create API Key |
|
||||
| `LITELLM_API_KEY` | 01, 04, 08 | LiteLLM virtual key (already in .env) |
|
||||
|
||||
@@ -97,6 +102,10 @@ Two steps required — both the `.env` file AND the `docker-compose.yaml` `envir
|
||||
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON: ${PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON}
|
||||
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON: ${PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON}
|
||||
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON: ${PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON}
|
||||
PAPERLESS_TRIAGE_MODEL: ${PAPERLESS_TRIAGE_MODEL}
|
||||
PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH: ${PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH}
|
||||
PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS: ${PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS}
|
||||
PAPERLESS_TRIAGE_APPLY_SAFE: ${PAPERLESS_TRIAGE_APPLY_SAFE}
|
||||
N8N_API_KEY: ${N8N_API_KEY}
|
||||
```
|
||||
|
||||
@@ -146,17 +155,18 @@ curl -X PUT http://10.5.1.6:6333/collections/knowledge_base \
|
||||
curl http://10.5.1.6:11434/api/pull -d '{"name": "nomic-embed-text"}'
|
||||
```
|
||||
|
||||
### Paperless Webhook (required for workflows 03 and 05)
|
||||
### Paperless Webhook (required for workflows 03, 05, 17, and 19)
|
||||
|
||||
In Paperless-NGX, set up post-consumption webhooks to POST to:
|
||||
```
|
||||
https://n8n.paccoco.com/webhook/paperless/new-document
|
||||
https://n8n.paccoco.com/webhook/paperless/rag-ingest
|
||||
https://n8n.paccoco.com/webhook/paperless/intake-triage
|
||||
https://n8n.paccoco.com/webhook/school/intake/paperless-enrich
|
||||
```
|
||||
with body: `{"document_id": <id>}`
|
||||
|
||||
You can trigger all three from the same Paperless event. Workflow 03 handles AI summary/tagging, workflow 05 handles RAG ingest, and workflow 19 re-applies deterministic school metadata for intake-managed documents.
|
||||
You can trigger all four from the same Paperless event. Workflow 03 handles general AI summary/tagging, workflow 05 handles RAG ingest, workflow 17 feeds the Doris Dashboard review queue, and workflow 19 re-applies deterministic school metadata for intake-managed documents.
|
||||
|
||||
### Grafana Webhook (already configured)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user