Compare commits
46 Commits
main
...
e5de7c532d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5de7c532d | ||
|
|
6278191614 | ||
|
|
66c97ae6b5 | ||
|
|
ed1e0110f2 | ||
|
|
cb8d6a030b | ||
|
|
5cc5b5f569 | ||
|
|
4d78dd4524 | ||
|
|
f7001d5130 | ||
|
|
ff2e13a16f | ||
|
|
5783963816 | ||
|
|
837d6e5866 | ||
|
|
458989e6cd | ||
|
|
79b546c70e | ||
|
|
1b61f7a669 | ||
|
|
88ea84b89a | ||
|
|
72724f8e2c | ||
|
|
d7670fde4b | ||
|
|
84b87baf2c | ||
|
|
80842f95a7 | ||
|
|
e567b97df8 | ||
|
|
bf5a138d1c | ||
|
|
0a398eea9a | ||
|
|
a5c0c0b9ad | ||
|
|
f6482abcd6 | ||
|
|
3269cc1fc5 | ||
|
|
a04b2eecd7 | ||
|
|
1b4838504b | ||
|
|
cf5474369d | ||
|
|
5ed02928ec | ||
|
|
7fe24d1956 | ||
|
|
30e01a9a09 | ||
|
|
e40fd48547 | ||
|
|
faf92cfbf7 | ||
| 1a6a74a067 | |||
|
|
2e0bd6d49a | ||
|
|
bc3722732a | ||
| 35077b1f7c | |||
| f3f1c346ac | |||
|
|
87dbd3e6ea | ||
|
|
96f1c69fd9 | ||
|
|
9aef43ee10 | ||
|
|
11904f39b2 | ||
|
|
35019e08cc | ||
|
|
ca3de65e34 | ||
|
|
6df8f80d24 | ||
|
|
8e8b54d176 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@ __pycache__/
|
|||||||
# Temp scripts
|
# Temp scripts
|
||||||
update-homelab-docs.py
|
update-homelab-docs.py
|
||||||
|
|
||||||
|
# Generated stack config
|
||||||
|
pihole/keepalived.conf
|
||||||
|
|
||||||
# Editor
|
# Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Docker Compose stacks for PlausibleDeniability (TrueNAS Scale), plus documentati
|
|||||||
| Server | Role | OS | IP |
|
| Server | Role | OS | IP |
|
||||||
|--------|------|----|----|
|
|--------|------|----|----|
|
||||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
||||||
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | LAN DHCP |
|
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | 10.5.1.6 |
|
||||||
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
||||||
|
|
||||||
## Repo Layout
|
## Repo Layout
|
||||||
@@ -17,7 +17,7 @@ databases/ # shared-postgres, shared-mariadb, shared-redis — deploy FIRS
|
|||||||
infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND
|
infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND
|
||||||
media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated
|
media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated
|
||||||
photos/ # immich server + machine learning
|
photos/ # immich server + machine learning
|
||||||
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges
|
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
|
||||||
dev/ # gitea, rackpeek
|
dev/ # gitea, rackpeek
|
||||||
ai/ # ollama, openwebui
|
ai/ # ollama, openwebui
|
||||||
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map
|
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ OPENWEBUI_DB_PASS=changeme
|
|||||||
|
|
||||||
# Scriberr — Hugging Face token for model downloads
|
# Scriberr — Hugging Face token for model downloads
|
||||||
HF_TOKEN=changeme
|
HF_TOKEN=changeme
|
||||||
|
|
||||||
|
# OpenClaw — generate with: openssl rand -hex 32
|
||||||
|
OPENCLAW_GATEWAY_TOKEN=changeme
|
||||||
|
|||||||
@@ -105,26 +105,3 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
whisper:
|
|
||||||
container_name: whisper
|
|
||||||
image: fedirz/faster-whisper-server:latest-cuda
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- ai-net
|
|
||||||
- ai-services
|
|
||||||
ports:
|
|
||||||
- "8786:8000"
|
|
||||||
environment:
|
|
||||||
TZ: ${TZ}
|
|
||||||
WHISPER__MODEL: ${WHISPER_MODEL}
|
|
||||||
WHISPER__DEVICE: cuda
|
|
||||||
WHISPER__COMPUTE_TYPE: float16
|
|
||||||
volumes:
|
|
||||||
- /mnt/docker-ssd/docker/appdata/whisper:/root/.cache/huggingface
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: 1
|
|
||||||
capabilities: [gpu]
|
|
||||||
|
|||||||
@@ -3,3 +3,35 @@ TZ=America/Chicago
|
|||||||
N8N_HOST=n8n.paccoco.com
|
N8N_HOST=n8n.paccoco.com
|
||||||
N8N_DB_PASS=CHANGE_ME
|
N8N_DB_PASS=CHANGE_ME
|
||||||
N8N_ENCRYPTION_KEY=CHANGE_ME
|
N8N_ENCRYPTION_KEY=CHANGE_ME
|
||||||
|
PAPERLESS_BASE_URL=https://paperless.paccoco.com
|
||||||
|
PAPERLESS_API_TOKEN=CHANGE_ME
|
||||||
|
PAPERLESS_CORRESPONDENT_SCHOOL=
|
||||||
|
PAPERLESS_DOCUMENT_TYPE_SCHOOL=
|
||||||
|
PAPERLESS_TAG_SCHOOL=
|
||||||
|
PAPERLESS_TAG_ASSIGNMENTS=
|
||||||
|
PAPERLESS_TAG_TELEGRAM=
|
||||||
|
PAPERLESS_TAG_CLASS_MAP_JSON={}
|
||||||
|
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON={}
|
||||||
|
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON={}
|
||||||
|
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON={}
|
||||||
|
NODE_FUNCTION_ALLOW_BUILTIN=crypto
|
||||||
|
|
||||||
|
# KitchenOwl recipe importer
|
||||||
|
KITCHENOWL_BASE_URL=https://owl.paccoco.com
|
||||||
|
KITCHENOWL_API_TOKEN=CHANGE_ME
|
||||||
|
KITCHENOWL_HOUSEHOLD_ID=1
|
||||||
|
|
||||||
|
# Backup automation scaffolding
|
||||||
|
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
|
||||||
|
BACKUP_KNOWN_HOSTS=/home/truenas_admin/.ssh/known_hosts
|
||||||
|
DOCKER_BIN=/usr/bin/docker
|
||||||
|
SUDO_BIN=/usr/bin/sudo
|
||||||
|
USE_SUDO_FOR_DOCKER=true
|
||||||
|
|||||||
88
automation/README.md
Normal file
88
automation/README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Automation helpers
|
||||||
|
|
||||||
|
This directory includes repo-side helpers for homelab operational tasks.
|
||||||
|
|
||||||
|
## KitchenOwl recipe import helper
|
||||||
|
|
||||||
|
- `bin/kitchenowl_recipe_import.py` — Doris-managed KitchenOwl recipe importer
|
||||||
|
- tries KitchenOwl's own scrape endpoint first
|
||||||
|
- falls back to direct page fetch + schema.org `Recipe` JSON-LD parsing
|
||||||
|
- normalizes ingredients/tags/timings into KitchenOwl's create-recipe payload
|
||||||
|
- supports dry-run by default and live create with `--create`
|
||||||
|
|
||||||
|
See `docs/operations/KITCHENOWL_RECIPE_IMPORT.md` for setup and usage.
|
||||||
|
|
||||||
|
## Backup scripts
|
||||||
|
|
||||||
|
- `bin/pd_backup_postgres.sh` — creates gzipped `pg_dump` exports for the configured Postgres DB list (quote space-separated DB names in `.env`, e.g. `POSTGRES_DB_LIST="n8n paperless"`)
|
||||||
|
- `bin/pd_backup_sync_to_serenity.sh` — `rsync`s configured backup/config roots to Serenity
|
||||||
|
- `bin/run_pd_backups.sh` — wrapper that runs both steps in order
|
||||||
|
|
||||||
|
These scripts are staged repo-side only. They still need:
|
||||||
|
|
||||||
|
1. real values in `.env`
|
||||||
|
2. a writable dump destination on PD
|
||||||
|
3. SSH trust / key path for Serenity (`root@10.5.1.5` by default)
|
||||||
|
- recommended known_hosts path on PD: `/home/truenas_admin/.ssh/known_hosts`
|
||||||
|
4. a real scheduler (cron/systemd timer/n8n) on the live host
|
||||||
|
|
||||||
|
## PD / TrueNAS deployment recommendation
|
||||||
|
|
||||||
|
Use a plain cron job on PD, not n8n and not a custom systemd timer.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- survives n8n outages
|
||||||
|
- keeps the backup runner close to the compose/data host
|
||||||
|
- avoids extra appliance fights on TrueNAS SCALE
|
||||||
|
- matches the existing shell-first operational style on PD
|
||||||
|
|
||||||
|
Preferred mode on PD: **root cron**.
|
||||||
|
|
||||||
|
Why: the dump path under `/mnt/tank/...` and Docker access are typically cleaner from root on TrueNAS than from a limited operator account.
|
||||||
|
|
||||||
|
TrueNAS-specific notes:
|
||||||
|
|
||||||
|
- scripts use `/usr/bin/bash` explicitly
|
||||||
|
- default Docker path is `/usr/bin/docker`
|
||||||
|
- if run as root, scripts call Docker directly
|
||||||
|
- if run as a non-root user, default behavior is `sudo -n /usr/bin/docker ...`
|
||||||
|
- keep all live paths under `/mnt/...`; never rely on rootfs write locations
|
||||||
|
|
||||||
|
## Example live flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/docker-ssd/docker/compose/automation
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||||
|
bin/run_pd_backups.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended PD cron block
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended first-run checklist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/docker-ssd/docker/compose/automation
|
||||||
|
cp .env.example .env # if not already present
|
||||||
|
chmod 600 .env
|
||||||
|
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||||
|
/usr/bin/bash bin/run_pd_backups.sh
|
||||||
|
tail -100 /mnt/tank/docker/backups/pd-backups.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety notes
|
||||||
|
|
||||||
|
- DB dumps are the priority restore artifacts; do not rely only on raw volume copies.
|
||||||
|
- The sync script uses `rsync --delete` inside the destination backup root, so point it at a dedicated backup path.
|
||||||
|
- Keep `.env` and SSH material out of git.
|
||||||
|
- If cron runs under a non-root PD account, `sudo -n /usr/bin/docker` must work or the DB dump step will fail.
|
||||||
475
automation/bin/kitchenowl_recipe_import.py
Executable file
475
automation/bin/kitchenowl_recipe_import.py
Executable file
@@ -0,0 +1,475 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Doris-managed KitchenOwl recipe importer.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Try KitchenOwl's native scrape endpoint for supported sites.
|
||||||
|
2. If that fails, fetch the page and extract schema.org Recipe JSON-LD.
|
||||||
|
3. Normalize ingredients/tags into KitchenOwl's create-recipe payload.
|
||||||
|
4. Optionally create the recipe via the KitchenOwl API.
|
||||||
|
|
||||||
|
Secrets stay in env vars or a local .env, never in git.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from html import unescape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib import error, parse, request
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
DEFAULT_ENV_PATH = REPO_ROOT / 'automation' / '.env'
|
||||||
|
|
||||||
|
UNIT_WORDS = {
|
||||||
|
'teaspoon','teaspoons','tsp','tsp.','tablespoon','tablespoons','tbsp','tbsp.','cup','cups',
|
||||||
|
'oz','ounce','ounces','lb','lbs','pound','pounds','gram','grams','g','kg','ml','l','pinch',
|
||||||
|
'dash','clove','cloves','can','cans','package','packages','pkg','slice','slices','stalk','stalks'
|
||||||
|
}
|
||||||
|
STOPWORDS = {'fresh','large','small','medium','optional','to','taste','for','the','a','an'}
|
||||||
|
TAG_KEYS = ('recipeCategory', 'recipeCuisine', 'keywords')
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptExtractor(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.in_script = False
|
||||||
|
self.script_type = None
|
||||||
|
self._buf: list[str] = []
|
||||||
|
self.scripts: list[tuple[str | None, str]] = []
|
||||||
|
self.title: str | None = None
|
||||||
|
self._in_title = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
attrs_d = dict(attrs)
|
||||||
|
if tag == 'script':
|
||||||
|
self.in_script = True
|
||||||
|
self.script_type = attrs_d.get('type')
|
||||||
|
self._buf = []
|
||||||
|
elif tag == 'title':
|
||||||
|
self._in_title = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
if tag == 'script' and self.in_script:
|
||||||
|
self.scripts.append((self.script_type, ''.join(self._buf)))
|
||||||
|
self.in_script = False
|
||||||
|
self.script_type = None
|
||||||
|
self._buf = []
|
||||||
|
elif tag == 'title':
|
||||||
|
self._in_title = False
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self.in_script:
|
||||||
|
self._buf.append(data)
|
||||||
|
elif self._in_title:
|
||||||
|
self.title = (self.title or '') + data
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for raw_line in path.read_text(encoding='utf-8').splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith('#') or '=' not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def env(name: str, default: str | None = None) -> str | None:
|
||||||
|
return os.environ.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def api_request(method: str, url: str, token: str, *, params: dict[str, Any] | None = None,
|
||||||
|
body: dict[str, Any] | None = None, timeout: int = DEFAULT_TIMEOUT) -> tuple[int, Any, dict[str, str]]:
|
||||||
|
if params:
|
||||||
|
url += ('&' if '?' in url else '?') + parse.urlencode(params, doseq=True)
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {token}',
|
||||||
|
'User-Agent': 'doris-kitchenowl-importer/1.0',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode('utf-8')
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
req = request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read().decode('utf-8', 'replace')
|
||||||
|
ctype = resp.headers.get('Content-Type', '')
|
||||||
|
payload = json.loads(raw) if 'json' in ctype else raw
|
||||||
|
return resp.status, payload, dict(resp.headers.items())
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
raw = exc.read().decode('utf-8', 'replace')
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
payload = raw
|
||||||
|
return exc.code, payload, dict(exc.headers.items())
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT) -> str:
|
||||||
|
req = request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Doris KitchenOwl Importer)',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
charset = resp.headers.get_content_charset() or 'utf-8'
|
||||||
|
return resp.read().decode(charset, 'replace')
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
if exc.code not in {403, 406, 429}:
|
||||||
|
raise
|
||||||
|
curl = subprocess.run(
|
||||||
|
[
|
||||||
|
'curl', '-fsSL', '--max-time', str(timeout),
|
||||||
|
'-A', 'Mozilla/5.0 (Doris KitchenOwl Importer)',
|
||||||
|
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'-H', 'Accept-Language: en-US,en;q=0.9',
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if curl.returncode != 0:
|
||||||
|
raise RuntimeError(f'Failed to fetch URL via urllib/curl: {url} ({curl.stderr.decode("utf-8", "replace").strip()})')
|
||||||
|
return curl.stdout.decode('utf-8', 'replace')
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_graph(node: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
if isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
out.extend(flatten_graph(item))
|
||||||
|
elif isinstance(node, dict):
|
||||||
|
if '@graph' in node:
|
||||||
|
out.extend(flatten_graph(node['@graph']))
|
||||||
|
else:
|
||||||
|
out.append(node)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def is_recipe_node(node: dict[str, Any]) -> bool:
|
||||||
|
t = node.get('@type')
|
||||||
|
if isinstance(t, list):
|
||||||
|
return 'Recipe' in t
|
||||||
|
return t == 'Recipe'
|
||||||
|
|
||||||
|
|
||||||
|
def choose_recipe_node(nodes: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||||
|
recipes = [n for n in nodes if is_recipe_node(n)]
|
||||||
|
if not recipes:
|
||||||
|
return None
|
||||||
|
recipes.sort(key=lambda r: len(r.get('recipeIngredient') or []), reverse=True)
|
||||||
|
return recipes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_ld_recipe(html: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
|
parser = ScriptExtractor()
|
||||||
|
parser.feed(html)
|
||||||
|
nodes: list[dict[str, Any]] = []
|
||||||
|
for script_type, content in parser.scripts:
|
||||||
|
if script_type and 'ld+json' not in script_type:
|
||||||
|
continue
|
||||||
|
content = content.strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
nodes.extend(flatten_graph(data))
|
||||||
|
return choose_recipe_node(nodes), (parser.title.strip() if parser.title else None)
|
||||||
|
|
||||||
|
|
||||||
|
DURATION_RE = re.compile(r'^P(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?$')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration_minutes(value: Any) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return int(value)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
m = DURATION_RE.match(value)
|
||||||
|
if m:
|
||||||
|
hours = int(m.group('hours') or 0)
|
||||||
|
minutes = int(m.group('minutes') or 0)
|
||||||
|
seconds = int(m.group('seconds') or 0)
|
||||||
|
total = hours * 60 + minutes + (1 if seconds >= 30 else 0)
|
||||||
|
return total or None
|
||||||
|
num = re.search(r'(\d+)', value)
|
||||||
|
return int(num.group(1)) if num else None
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_text(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
if isinstance(value, list):
|
||||||
|
return '\n'.join(coerce_text(v) for v in value if coerce_text(v))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if 'text' in value:
|
||||||
|
return coerce_text(value['text'])
|
||||||
|
if '@value' in value:
|
||||||
|
return coerce_text(value['@value'])
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
return unescape(str(value)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def slugify_tag(value: str) -> str:
|
||||||
|
value = value.strip().lower()
|
||||||
|
value = re.sub(r'[^a-z0-9]+', '-', value)
|
||||||
|
return value.strip('-')[:48]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_keywords(value: Any) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts = re.split(r'[,;|]', value)
|
||||||
|
out.extend(p.strip() for p in parts if p.strip())
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
out.extend(normalize_keywords(item))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def split_ingredient(line: str) -> tuple[str, str]:
|
||||||
|
original = ' '.join(line.strip().split())
|
||||||
|
if not original:
|
||||||
|
return '', ''
|
||||||
|
cleaned = original.replace('–', '-').replace('—', '-')
|
||||||
|
tokens = cleaned.split()
|
||||||
|
desc_tokens: list[str] = []
|
||||||
|
name_tokens = tokens[:]
|
||||||
|
|
||||||
|
while name_tokens:
|
||||||
|
token = name_tokens[0].strip(',()').lower()
|
||||||
|
if re.fullmatch(r'[\d/.-]+', token) or token in {'x', '×'}:
|
||||||
|
desc_tokens.append(name_tokens.pop(0))
|
||||||
|
continue
|
||||||
|
if token in UNIT_WORDS:
|
||||||
|
desc_tokens.append(name_tokens.pop(0))
|
||||||
|
continue
|
||||||
|
if token in {'of'} and desc_tokens:
|
||||||
|
desc_tokens.append(name_tokens.pop(0))
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if not name_tokens:
|
||||||
|
return original[:128], ''
|
||||||
|
|
||||||
|
while name_tokens and name_tokens[0].strip(',').lower() in STOPWORDS and len(name_tokens) > 1:
|
||||||
|
desc_tokens.append(name_tokens.pop(0))
|
||||||
|
|
||||||
|
name = ' '.join(name_tokens).strip(' ,;')
|
||||||
|
if ',' in name:
|
||||||
|
first, rest = [p.strip() for p in name.split(',', 1)]
|
||||||
|
if first and len(first.split()) <= 5:
|
||||||
|
name = first
|
||||||
|
if rest:
|
||||||
|
desc_tokens.append(rest)
|
||||||
|
description = ' '.join(desc_tokens).strip(' ,;')
|
||||||
|
return name[:128], description[:512]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_recipe(url: str, recipe_node: dict[str, Any], title_fallback: str | None = None) -> dict[str, Any]:
|
||||||
|
name = coerce_text(recipe_node.get('name')) or (title_fallback or url)
|
||||||
|
description_parts: list[str] = []
|
||||||
|
desc = coerce_text(recipe_node.get('description'))
|
||||||
|
instructions = recipe_node.get('recipeInstructions')
|
||||||
|
instructions_text = coerce_text(instructions)
|
||||||
|
if desc:
|
||||||
|
description_parts.append(desc)
|
||||||
|
if instructions_text:
|
||||||
|
description_parts.append(instructions_text)
|
||||||
|
description = '\n\n'.join(part for part in description_parts if part).strip()
|
||||||
|
|
||||||
|
ingredients = recipe_node.get('recipeIngredient') or []
|
||||||
|
items = []
|
||||||
|
seen_items: set[tuple[str, str]] = set()
|
||||||
|
for raw in ingredients:
|
||||||
|
line = coerce_text(raw)
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
item_name, item_desc = split_ingredient(line)
|
||||||
|
if not item_name:
|
||||||
|
item_name = line[:128]
|
||||||
|
key = (item_name.lower(), item_desc.lower())
|
||||||
|
if key in seen_items:
|
||||||
|
continue
|
||||||
|
seen_items.add(key)
|
||||||
|
items.append({'name': item_name, 'description': item_desc, 'optional': False})
|
||||||
|
|
||||||
|
tags: list[str] = []
|
||||||
|
for key in TAG_KEYS:
|
||||||
|
tags.extend(normalize_keywords(recipe_node.get(key)))
|
||||||
|
deduped_tags: list[str] = []
|
||||||
|
seen_tags: set[str] = set()
|
||||||
|
for tag in tags:
|
||||||
|
slug = slugify_tag(tag)
|
||||||
|
if slug and slug not in seen_tags:
|
||||||
|
seen_tags.add(slug)
|
||||||
|
deduped_tags.append(slug)
|
||||||
|
|
||||||
|
yields_val = recipe_node.get('recipeYield')
|
||||||
|
yields_num = None
|
||||||
|
if isinstance(yields_val, list) and yields_val:
|
||||||
|
yields_val = yields_val[0]
|
||||||
|
if yields_val is not None:
|
||||||
|
match = re.search(r'(\d+)', str(yields_val))
|
||||||
|
if match:
|
||||||
|
yields_num = int(match.group(1))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'name': name[:128],
|
||||||
|
'description': description[:20000],
|
||||||
|
'source': url,
|
||||||
|
'visibility': 0,
|
||||||
|
'items': items,
|
||||||
|
'tags': deduped_tags[:20],
|
||||||
|
}
|
||||||
|
cook_time = parse_duration_minutes(recipe_node.get('cookTime'))
|
||||||
|
prep_time = parse_duration_minutes(recipe_node.get('prepTime'))
|
||||||
|
total_time = parse_duration_minutes(recipe_node.get('totalTime'))
|
||||||
|
if cook_time is not None:
|
||||||
|
payload['cook_time'] = cook_time
|
||||||
|
if prep_time is not None:
|
||||||
|
payload['prep_time'] = prep_time
|
||||||
|
if total_time is not None:
|
||||||
|
payload['time'] = total_time
|
||||||
|
elif cook_time is not None or prep_time is not None:
|
||||||
|
payload['time'] = (cook_time or 0) + (prep_time or 0)
|
||||||
|
if yields_num is not None:
|
||||||
|
payload['yields'] = yields_num
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_recipe(base_url: str, token: str, household_id: int, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
status, data, _ = api_request('GET', f'{base_url}/api/household/{household_id}/recipe', token)
|
||||||
|
if status != 200 or not isinstance(data, list):
|
||||||
|
return None
|
||||||
|
want_source = (payload.get('source') or '').strip().lower()
|
||||||
|
want_name = (payload.get('name') or '').strip().lower()
|
||||||
|
for recipe in data:
|
||||||
|
source = str(recipe.get('source') or '').strip().lower()
|
||||||
|
name = str(recipe.get('name') or '').strip().lower()
|
||||||
|
if want_source and source and source == want_source:
|
||||||
|
return recipe
|
||||||
|
if want_name and name == want_name:
|
||||||
|
return recipe
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def import_one(args: argparse.Namespace, base_url: str, token: str, household_id: int, url: str) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {'url': url}
|
||||||
|
payload = None
|
||||||
|
scrape_error = None
|
||||||
|
|
||||||
|
if not args.skip_kitchenowl_scrape:
|
||||||
|
status, data, _ = api_request('GET', f'{base_url}/api/household/{household_id}/recipe/scrape', token, params={'url': url}, timeout=args.timeout)
|
||||||
|
if status == 200 and isinstance(data, dict):
|
||||||
|
payload = data
|
||||||
|
result['strategy'] = 'kitchenowl-scrape'
|
||||||
|
else:
|
||||||
|
scrape_error = {'status': status, 'detail': data}
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
html = fetch_url(url, timeout=args.timeout)
|
||||||
|
recipe_node, title = parse_json_ld_recipe(html)
|
||||||
|
if not recipe_node:
|
||||||
|
raise RuntimeError(f'No schema.org Recipe JSON-LD found for {url}')
|
||||||
|
payload = normalize_recipe(url, recipe_node, title)
|
||||||
|
result['strategy'] = 'json-ld-fallback'
|
||||||
|
if scrape_error:
|
||||||
|
result['scrape_error'] = scrape_error
|
||||||
|
|
||||||
|
payload['visibility'] = args.visibility
|
||||||
|
result['payload'] = payload
|
||||||
|
|
||||||
|
existing = None if args.allow_duplicates else find_existing_recipe(base_url, token, household_id, payload)
|
||||||
|
if existing:
|
||||||
|
result['existing'] = {'id': existing.get('id'), 'name': existing.get('name'), 'source': existing.get('source')}
|
||||||
|
result['status'] = 'duplicate'
|
||||||
|
return result
|
||||||
|
|
||||||
|
if args.create:
|
||||||
|
status, created, _ = api_request('POST', f'{base_url}/api/household/{household_id}/recipe', token, body=payload, timeout=args.timeout)
|
||||||
|
result['create_status'] = status
|
||||||
|
result['created'] = created
|
||||||
|
result['status'] = 'created' if status == 200 else 'error'
|
||||||
|
else:
|
||||||
|
result['status'] = 'dry-run'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description='Import recipe links into KitchenOwl with Doris-managed fallback parsing.')
|
||||||
|
p.add_argument('urls', nargs='+', help='Recipe URLs to normalize/import')
|
||||||
|
p.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Optional .env file to load before reading env vars')
|
||||||
|
p.add_argument('--base-url', default=None, help='KitchenOwl base URL, e.g. https://owl.paccoco.com')
|
||||||
|
p.add_argument('--token', default=None, help='KitchenOwl bearer token (prefer env var instead)')
|
||||||
|
p.add_argument('--household-id', type=int, default=None, help='KitchenOwl household id')
|
||||||
|
p.add_argument('--create', action='store_true', help='Create recipes in KitchenOwl. Default is dry-run only.')
|
||||||
|
p.add_argument('--allow-duplicates', action='store_true', help='Do not skip name/source duplicates.')
|
||||||
|
p.add_argument('--skip-kitchenowl-scrape', action='store_true', help='Go straight to Doris JSON-LD parsing.')
|
||||||
|
p.add_argument('--visibility', type=int, choices=[0,1,2], default=0, help='Recipe visibility: 0 private, 1 link, 2 public.')
|
||||||
|
p.add_argument('--pretty', action='store_true', help='Pretty-print JSON output')
|
||||||
|
p.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT, help='HTTP timeout in seconds')
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
load_env_file(Path(args.env_file).expanduser())
|
||||||
|
|
||||||
|
base_url = (args.base_url or env('KITCHENOWL_BASE_URL') or env('KITCHENOWL_URL') or '').rstrip('/')
|
||||||
|
token = args.token or env('KITCHENOWL_API_TOKEN') or ''
|
||||||
|
household_raw = args.household_id or env('KITCHENOWL_HOUSEHOLD_ID')
|
||||||
|
|
||||||
|
if not base_url:
|
||||||
|
print('Missing KitchenOwl base URL. Set KITCHENOWL_BASE_URL or KITCHENOWL_URL.', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not token:
|
||||||
|
print('Missing KitchenOwl API token. Set KITCHENOWL_API_TOKEN.', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not household_raw:
|
||||||
|
print('Missing KitchenOwl household id. Set KITCHENOWL_HOUSEHOLD_ID.', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
household_id = int(household_raw)
|
||||||
|
results = []
|
||||||
|
failures = 0
|
||||||
|
for url in args.urls:
|
||||||
|
try:
|
||||||
|
results.append(import_one(args, base_url, token, household_id, url))
|
||||||
|
except Exception as exc:
|
||||||
|
failures += 1
|
||||||
|
results.append({'url': url, 'status': 'error', 'error': str(exc)})
|
||||||
|
|
||||||
|
dump = json.dumps(results, ensure_ascii=False, indent=2 if args.pretty else None)
|
||||||
|
print(dump)
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
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."
|
||||||
@@ -38,6 +38,20 @@ services:
|
|||||||
N8N_EMAIL_MODE: smtp
|
N8N_EMAIL_MODE: smtp
|
||||||
EXECUTIONS_DATA_PRUNE: "true"
|
EXECUTIONS_DATA_PRUNE: "true"
|
||||||
EXECUTIONS_DATA_MAX_AGE: 336
|
EXECUTIONS_DATA_MAX_AGE: 336
|
||||||
|
N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"
|
||||||
|
LITELLM_API_KEY: ${LITELLM_API_KEY}
|
||||||
|
PAPERLESS_BASE_URL: ${PAPERLESS_BASE_URL}
|
||||||
|
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
|
||||||
|
PAPERLESS_CORRESPONDENT_SCHOOL: ${PAPERLESS_CORRESPONDENT_SCHOOL}
|
||||||
|
PAPERLESS_DOCUMENT_TYPE_SCHOOL: ${PAPERLESS_DOCUMENT_TYPE_SCHOOL}
|
||||||
|
PAPERLESS_TAG_SCHOOL: ${PAPERLESS_TAG_SCHOOL}
|
||||||
|
PAPERLESS_TAG_ASSIGNMENTS: ${PAPERLESS_TAG_ASSIGNMENTS}
|
||||||
|
PAPERLESS_TAG_TELEGRAM: ${PAPERLESS_TAG_TELEGRAM}
|
||||||
|
PAPERLESS_TAG_CLASS_MAP_JSON: ${PAPERLESS_TAG_CLASS_MAP_JSON}
|
||||||
|
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}
|
||||||
|
NODE_FUNCTION_ALLOW_BUILTIN: ${NODE_FUNCTION_ALLOW_BUILTIN}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/tank/docker/appdata/n8n:/home/node/.n8n
|
- /mnt/tank/docker/appdata/n8n:/home/node/.n8n
|
||||||
- /mnt/data/class-recordings:/data/class-recordings
|
- /mnt/data/class-recordings:/data/class-recordings
|
||||||
|
|||||||
0
databases/initdb-mariadb/01-create-uptime-kuma-db.sh
Executable file → Normal file
0
databases/initdb-mariadb/01-create-uptime-kuma-db.sh
Executable file → Normal file
@@ -39,6 +39,25 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
|
# Run once to install git-crypt on the TrueNAS host (apt is blocked there).
|
||||||
|
# Usage: docker compose --profile setup up git-crypt-init
|
||||||
|
git-crypt-init:
|
||||||
|
container_name: git-crypt-init
|
||||||
|
image: debian:bookworm-slim
|
||||||
|
profiles:
|
||||||
|
- setup
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- /root/bin:/out
|
||||||
|
command: >
|
||||||
|
bash -c "
|
||||||
|
apt-get update -qq &&
|
||||||
|
apt-get install -y -qq git-crypt &&
|
||||||
|
cp /usr/bin/git-crypt /out/git-crypt &&
|
||||||
|
chmod +x /out/git-crypt &&
|
||||||
|
echo 'git-crypt installed to /root/bin/git-crypt'
|
||||||
|
"
|
||||||
|
|
||||||
rackpeek:
|
rackpeek:
|
||||||
container_name: ix-rackpeek-rackpeek-1
|
container_name: ix-rackpeek-rackpeek-1
|
||||||
image: aptacode/rackpeek:latest
|
image: aptacode/rackpeek:latest
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
|
|||||||
- **Homepage** (3300) — dashboard / service index
|
- **Homepage** (3300) — dashboard / service index
|
||||||
- **Uptime Kuma** (3001) — monitoring and alerting
|
- **Uptime Kuma** (3001) — monitoring and alerting
|
||||||
- **Gotify** (8443) — push notifications
|
- **Gotify** (8443) — push notifications
|
||||||
- **Gitea** (3002) — self-hosted git
|
- **Gitea** (3002 web / 2222 SSH) — self-hosted git; internal SSH remote uses `ssh://git@10.5.1.6:2222/<owner>/<repo>.git`
|
||||||
- **Dockhand** (3230) — Docker management UI
|
- **Dockhand** (3230) — Docker management UI
|
||||||
|
|
||||||
### AI Layer
|
### AI Layer
|
||||||
@@ -41,7 +41,7 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
|
|||||||
|
|
||||||
### Productivity Layer
|
### Productivity Layer
|
||||||
- **Immich** (2283) — photo management; library on Serenity NFS
|
- **Immich** (2283) — photo management; library on Serenity NFS
|
||||||
- **Paperless-NGX** (8083) — document management
|
- **Paperless-NGX** (8083) — document management; uses internal Tika + Gotenberg sidecars for Office/OpenDocument text extraction and conversion
|
||||||
- **Karakeep** (3100) — bookmarks / read-later
|
- **Karakeep** (3100) — bookmarks / read-later
|
||||||
- **n8n** (5678) — workflow automation
|
- **n8n** (5678) — workflow automation
|
||||||
- **KitchenOwl** (8086), **DoneTick** (2021) — household and task management
|
- **KitchenOwl** (8086), **DoneTick** (2021) — household and task management
|
||||||
@@ -61,4 +61,4 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
|
|||||||
3. **External Docker networks** — `ai-services` bridges all AI containers across compose files; `ix-databases_shared-databases` provides shared DB access
|
3. **External Docker networks** — `ai-services` bridges all AI containers across compose files; `ix-databases_shared-databases` provides shared DB access
|
||||||
4. **NFS for Immich** — photo library lives on Serenity's large array; PD mounts on demand (not at boot)
|
4. **NFS for Immich** — photo library lives on Serenity's large array; PD mounts on demand (not at boot)
|
||||||
5. **TrueNAS manages Docker** — never use `systemctl restart docker` on PD; use `docker compose` or `docker restart` for individual containers
|
5. **TrueNAS manages Docker** — never use `systemctl restart docker` on PD; use `docker compose` or `docker restart` for individual containers
|
||||||
6. **Credential hygiene** — Gitea remote uses HTTPS + credential store; GitHub remote uses SSH key (`/root/.ssh/id_gitea`)
|
6. **Credential hygiene** — Gitea pushes from PD/NOMAD use internal SSH (`ssh://git@10.5.1.6:2222/<owner>/<repo>.git`); GitHub remote uses SSH key (`/root/.ssh/id_gitea`)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- **Other VLANs:** Cameras, Guest, IoT
|
- **Other VLANs:** Cameras, Guest, IoT
|
||||||
- **Serenity IP:** 10.5.1.5
|
- **Serenity IP:** 10.5.1.5
|
||||||
- **N.O.M.A.D. IP:** 10.5.1.16
|
- **N.O.M.A.D. IP:** 10.5.1.16
|
||||||
- **PlausibleDeniability:** DHCP on LAN
|
- **PlausibleDeniability:** 10.5.1.6 (static on LAN)
|
||||||
|
|
||||||
## Remote Access
|
## Remote Access
|
||||||
- **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D.
|
- **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
All homelab services, their hosts, ports, and current status.
|
All homelab services, their hosts, ports, and current status.
|
||||||
|
|
||||||
*Last updated: 2026-05-09*
|
*Last updated: 2026-05-10*
|
||||||
|
|
||||||
## Shared Databases (PD)
|
## Shared Databases (PD)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ All homelab services, their hosts, ports, and current status.
|
|||||||
| Dockhand | 3230 | http://pd:3230 | ✅ Active |
|
| Dockhand | 3230 | http://pd:3230 | ✅ Active |
|
||||||
| Uptime Kuma | 3001 | http://pd:3001 | ✅ Active |
|
| Uptime Kuma | 3001 | http://pd:3001 | ✅ Active |
|
||||||
| Gotify | 8443 | https://pd:8443 | ✅ Active |
|
| Gotify | 8443 | https://pd:8443 | ✅ Active |
|
||||||
| Gitea | 3002 / 2222 | http://pd:3002 (SSH: 2222) | ✅ Active |
|
| Gitea | 3002 / 2222 | http://pd:3002 / http://10.5.1.6:3002 (SSH: `ssh://git@10.5.1.6:2222/<owner>/<repo>.git`) | ✅ Active |
|
||||||
| Shlink | 8087 | http://pd:8087 | ✅ Active |
|
| Shlink | 8087 | http://pd:8087 | ✅ Active |
|
||||||
| Shlink Web Client | 8088 | http://pd:8088 | ✅ Active |
|
| Shlink Web Client | 8088 | http://pd:8088 | ✅ Active |
|
||||||
| RackPeek | 8283 | http://pd:8283 | ✅ Active |
|
| RackPeek | 8283 | http://pd:8283 | ✅ Active |
|
||||||
@@ -32,8 +32,10 @@ All homelab services, their hosts, ports, and current status.
|
|||||||
| LiteLLM | PD | 4000 | ✅ Active |
|
| LiteLLM | PD | 4000 | ✅ Active |
|
||||||
| OpenWebUI | PD | 8282 | ✅ Active |
|
| OpenWebUI | PD | 8282 | ✅ Active |
|
||||||
| Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active |
|
| Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active |
|
||||||
| Whisper (faster-whisper) | PD | 8786 | ✅ Active |
|
| Whisper (faster-whisper, CPU) | N.O.M.A.D. (10.5.1.16) | 8786 | ✅ Active |
|
||||||
|
| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.1.112) | 8787 | ✅ Active |
|
||||||
| SearXNG | PD | 8888 | ✅ Active |
|
| SearXNG | PD | 8888 | ✅ Active |
|
||||||
|
| OpenClaw | PD | 18789 | ✅ Active |
|
||||||
| Ollama — light tier | N.O.M.A.D. (10.5.1.16) | 11434 | ✅ Active |
|
| Ollama — light tier | N.O.M.A.D. (10.5.1.16) | 11434 | ✅ Active |
|
||||||
| Ollama — heavy tier | Rocinante (10.5.1.112) | 11434 | ✅ Active |
|
| Ollama — heavy tier | Rocinante (10.5.1.112) | 11434 | ✅ Active |
|
||||||
| Reranker (TEI) | Serenity (10.5.1.5) | 9787 | ✅ Active |
|
| Reranker (TEI) | Serenity (10.5.1.5) | 9787 | ✅ Active |
|
||||||
@@ -53,7 +55,7 @@ All homelab services, their hosts, ports, and current status.
|
|||||||
| Service | Port | Status |
|
| Service | Port | Status |
|
||||||
|---------|------|--------|
|
|---------|------|--------|
|
||||||
| Immich | 2283 | ✅ Active |
|
| Immich | 2283 | ✅ Active |
|
||||||
| Paperless-NGX | 8083 | ✅ Active |
|
| Paperless-NGX | 8083 | ✅ Active; DOCX/Office ingestion via internal Tika + Gotenberg sidecars |
|
||||||
| Karakeep | 3100 | ✅ Active |
|
| Karakeep | 3100 | ✅ Active |
|
||||||
| n8n | 5678 | ✅ Active |
|
| n8n | 5678 | ✅ Active |
|
||||||
| KitchenOwl | 8086 | ✅ Active |
|
| KitchenOwl | 8086 | ✅ Active |
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ Back up at minimum:
|
|||||||
|
|
||||||
Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
|
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
|
## .env File Backup Options
|
||||||
1. **Private Gitea repo** — short term, push all .env files to a private repo on your local Gitea
|
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)
|
2. **Encrypted restic** — include .env files in restic backup with encryption, push offsite (Backblaze B2)
|
||||||
|
|||||||
137
docs/operations/KITCHENOWL_RECIPE_IMPORT.md
Normal file
137
docs/operations/KITCHENOWL_RECIPE_IMPORT.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# KitchenOwl Recipe Import (Doris-managed)
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
KitchenOwl's built-in import/scrape flow is unreliable for the way John and Leanne actually send recipe links around.
|
||||||
|
|
||||||
|
This repo-side helper gives Doris a safer path:
|
||||||
|
|
||||||
|
1. try KitchenOwl's native `/recipe/scrape` endpoint for sites it understands
|
||||||
|
2. if that fails, fetch the page directly
|
||||||
|
3. extract schema.org `Recipe` JSON-LD
|
||||||
|
4. normalize ingredients/tags/timings into KitchenOwl's create-recipe payload
|
||||||
|
5. optionally create the recipe through the KitchenOwl API
|
||||||
|
|
||||||
|
That means Doris can ingest recipe links from chat without depending on KitchenOwl's flaky native importer.
|
||||||
|
|
||||||
|
## Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
automation/bin/kitchenowl_recipe_import.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Default mode is **dry-run**. It prints the normalized KitchenOwl payload and does **not** create anything unless `--create` is passed.
|
||||||
|
|
||||||
|
## Required env vars
|
||||||
|
|
||||||
|
Put these in a live `.env` file or export them ad hoc. Do **not** commit live secrets.
|
||||||
|
|
||||||
|
```env
|
||||||
|
KITCHENOWL_BASE_URL=https://owl.paccoco.com
|
||||||
|
KITCHENOWL_API_TOKEN=CHANGE_ME
|
||||||
|
KITCHENOWL_HOUSEHOLD_ID=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `KITCHENOWL_API_TOKEN` should be a long-lived token for a user who can create recipes in the target household.
|
||||||
|
- `KITCHENOWL_HOUSEHOLD_ID` for the current live instance is `1` (`Stafford's`) unless that changes later.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Dry-run a link
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/fizzlepoof/repos/truenas-stacks
|
||||||
|
export KITCHENOWL_BASE_URL=https://owl.paccoco.com
|
||||||
|
export KITCHENOWL_API_TOKEN=...redacted...
|
||||||
|
export KITCHENOWL_HOUSEHOLD_ID=1
|
||||||
|
|
||||||
|
automation/bin/kitchenowl_recipe_import.py --pretty \
|
||||||
|
"https://example.com/my-recipe"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actually create the recipe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
automation/bin/kitchenowl_recipe_import.py --pretty --create \
|
||||||
|
"https://example.com/my-recipe"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import several links at once
|
||||||
|
|
||||||
|
```bash
|
||||||
|
automation/bin/kitchenowl_recipe_import.py --create --pretty \
|
||||||
|
"https://example.com/recipe-1" \
|
||||||
|
"https://example.com/recipe-2"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior details
|
||||||
|
|
||||||
|
### Strategy order
|
||||||
|
|
||||||
|
By default the helper tries:
|
||||||
|
|
||||||
|
1. `GET /api/household/<id>/recipe/scrape?url=...`
|
||||||
|
2. if KitchenOwl returns `Unsupported website` or otherwise fails, Doris falls back to direct HTML fetch + JSON-LD parse
|
||||||
|
|
||||||
|
If you want to skip the built-in scrape and go straight to Doris mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
automation/bin/kitchenowl_recipe_import.py --skip-kitchenowl-scrape --pretty URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Duplicate protection
|
||||||
|
|
||||||
|
Before creating a recipe, the helper loads existing household recipes and skips creation if it finds:
|
||||||
|
- an exact matching `source` URL, or
|
||||||
|
- an exact matching recipe `name`
|
||||||
|
|
||||||
|
To force duplicates anyway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
automation/bin/kitchenowl_recipe_import.py --create --allow-duplicates URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
|
||||||
|
KitchenOwl visibility enum:
|
||||||
|
- `0` = private (default)
|
||||||
|
- `1` = link
|
||||||
|
- `2` = public
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
automation/bin/kitchenowl_recipe_import.py --create --visibility 0 URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## What parses well
|
||||||
|
|
||||||
|
The fallback parser works best on recipe pages that expose proper schema.org `Recipe` JSON-LD, which many decent recipe sites do.
|
||||||
|
|
||||||
|
It pulls:
|
||||||
|
- name
|
||||||
|
- description
|
||||||
|
- instructions
|
||||||
|
- ingredient list
|
||||||
|
- total/prep/cook time
|
||||||
|
- yield/servings
|
||||||
|
- categories/cuisine/keywords → KitchenOwl tags
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Some sites block scraping aggressively; Doris first tries normal fetch, then falls back to `curl` headers for better odds.
|
||||||
|
- Ingredient normalization is heuristic, not magic. It aims for a sane KitchenOwl item name plus a description/amount, but some weird ingredients may still need manual cleanup.
|
||||||
|
- Image upload/import is not wired yet in this helper.
|
||||||
|
- The helper expects structured JSON-LD. If a site hides the recipe entirely in client-side blobs or anti-bot nonsense, manual copy/paste may still be needed.
|
||||||
|
- `GET /api/settings` on the live KitchenOwl instance currently returns `500`, but that does not block recipe import.
|
||||||
|
|
||||||
|
## Doris workflow expectation
|
||||||
|
|
||||||
|
Preferred operator flow when John or Leanne sends recipe links:
|
||||||
|
|
||||||
|
1. Doris runs a dry-run first when the site is unfamiliar or suspicious.
|
||||||
|
2. If the normalized payload looks sane, Doris reruns with `--create`.
|
||||||
|
3. If the page is too messy, Doris asks for the raw ingredients/steps and imports it manually.
|
||||||
|
|
||||||
|
That keeps KitchenOwl as the recipe store while making Doris the reliable intake layer.
|
||||||
49
docs/operations/PAPERLESS.md
Normal file
49
docs/operations/PAPERLESS.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Paperless-NGX Operations
|
||||||
|
|
||||||
|
## Stack Location
|
||||||
|
|
||||||
|
- Compose path on PD: `/mnt/docker-ssd/docker/compose/documents/docker-compose.yaml`
|
||||||
|
- Appdata: `/mnt/docker-ssd/docker/appdata/paperless/`
|
||||||
|
- Consume source: `/mnt/unraid/data/paperless/consume`
|
||||||
|
- Consume container path: `/usr/src/paperless/consume`
|
||||||
|
|
||||||
|
## Office Document Ingestion
|
||||||
|
|
||||||
|
Paperless-NGX uses internal-only Tika and Gotenberg sidecars:
|
||||||
|
|
||||||
|
- Tika endpoint: `http://tika:9998`
|
||||||
|
- Gotenberg endpoint: `http://gotenberg:3000`
|
||||||
|
- Network: `documents_paperless_internal`
|
||||||
|
- Host ports: none
|
||||||
|
- Resource limits: Tika `512m`, Gotenberg `256m`
|
||||||
|
|
||||||
|
This enables ingestion/extraction for Office and OpenDocument files such as:
|
||||||
|
|
||||||
|
- `.docx`, `.doc`
|
||||||
|
- `.pptx`, `.xlsx`
|
||||||
|
- `.odt`, `.ods`, `.odp`
|
||||||
|
|
||||||
|
## Deploy / Restart Procedure
|
||||||
|
|
||||||
|
Validate before changing running containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/docker-ssd/docker/compose/documents
|
||||||
|
docker compose --env-file .env config
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy with compose only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env down && docker compose --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use `systemctl restart docker` on PD for this stack.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env ps
|
||||||
|
docker logs paperless --tail 200 | grep -Ei 'tika|gotenberg'
|
||||||
|
docker exec paperless sh -lc 'echo "$PAPERLESS_TIKA_ENABLED $PAPERLESS_TIKA_ENDPOINT $PAPERLESS_TIKA_GOTENBERG_ENDPOINT"; mount | grep /usr/src/paperless/consume'
|
||||||
|
```
|
||||||
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.
|
||||||
130
docs/operations/SCHOOL_PAPERLESS_INTAKE.md
Normal file
130
docs/operations/SCHOOL_PAPERLESS_INTAKE.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# School Paperless Intake Workflow
|
||||||
|
|
||||||
|
Current workflow versions:
|
||||||
|
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
|
||||||
|
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
|
||||||
|
|
||||||
|
This workflow set is meant for a Telegram chat-driven upload pipeline that sends schoolwork into Paperless-NGX while recording intake metadata in shared Postgres.
|
||||||
|
|
||||||
|
## Versioning rule
|
||||||
|
|
||||||
|
When these workflow artifacts change, the version must change too:
|
||||||
|
- the filename keeps a `-vX.Y` suffix
|
||||||
|
- the internal n8n workflow `name` also carries the same version
|
||||||
|
- docs and testing notes should reference the current version explicitly
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
### Workflow 18 — v1.2
|
||||||
|
|
||||||
|
1. Accepts a multipart upload at `POST /webhook/school/intake/upload`
|
||||||
|
2. Requires form fields:
|
||||||
|
- `class_name`
|
||||||
|
- `assignment_name`
|
||||||
|
- `submission_kind`
|
||||||
|
3. Accepts optional fields:
|
||||||
|
- `semester`
|
||||||
|
- `course_code`
|
||||||
|
- `paper_kind`
|
||||||
|
- `notes`
|
||||||
|
- `source` (defaults to `telegram`)
|
||||||
|
- `telegram_chat_id`
|
||||||
|
- `telegram_message_id`
|
||||||
|
4. Generates a deterministic `intake_id` from date + class + assignment + file checksum
|
||||||
|
5. Stores/updates the intake row in Postgres first
|
||||||
|
6. Uploads the file to Paperless using the deterministic intake ID in the filename
|
||||||
|
7. Marks the row as `uploaded` after Paperless accepts the upload
|
||||||
|
|
||||||
|
## Shared Postgres expectations
|
||||||
|
|
||||||
|
- Use the shared Postgres network/credentials pattern already used by the automation stack.
|
||||||
|
- Earlier drafts used shared Postgres tracking, but the current live-tested v1.2/v1.1 path works without Postgres dependency in the webhook chain.
|
||||||
|
- Deterministic filename/title are now the source of correlation between upload and enrichment.
|
||||||
|
- Tracked schema: `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql` (kept for future DB-backed intake tracking if reinstated)
|
||||||
|
|
||||||
|
## Required environment variables
|
||||||
|
|
||||||
|
Add to `automation/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PAPERLESS_BASE_URL=https://paperless.paccoco.com
|
||||||
|
PAPERLESS_API_TOKEN=CHANGE_ME
|
||||||
|
LITELLM_API_KEY=CHANGE_ME
|
||||||
|
PAPERLESS_CORRESPONDENT_SCHOOL=
|
||||||
|
PAPERLESS_DOCUMENT_TYPE_SCHOOL=
|
||||||
|
PAPERLESS_TAG_SCHOOL=
|
||||||
|
PAPERLESS_TAG_ASSIGNMENTS=
|
||||||
|
PAPERLESS_TAG_TELEGRAM=
|
||||||
|
PAPERLESS_TAG_CLASS_MAP_JSON={}
|
||||||
|
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON={}
|
||||||
|
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON={}
|
||||||
|
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON={}
|
||||||
|
NODE_FUNCTION_ALLOW_BUILTIN=crypto
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `n8n` service `environment:` block in `automation/docker-compose.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
PAPERLESS_BASE_URL: ${PAPERLESS_BASE_URL}
|
||||||
|
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
|
||||||
|
PAPERLESS_CORRESPONDENT_SCHOOL: ${PAPERLESS_CORRESPONDENT_SCHOOL}
|
||||||
|
PAPERLESS_DOCUMENT_TYPE_SCHOOL: ${PAPERLESS_DOCUMENT_TYPE_SCHOOL}
|
||||||
|
PAPERLESS_TAG_SCHOOL: ${PAPERLESS_TAG_SCHOOL}
|
||||||
|
PAPERLESS_TAG_ASSIGNMENTS: ${PAPERLESS_TAG_ASSIGNMENTS}
|
||||||
|
PAPERLESS_TAG_TELEGRAM: ${PAPERLESS_TAG_TELEGRAM}
|
||||||
|
PAPERLESS_TAG_CLASS_MAP_JSON: ${PAPERLESS_TAG_CLASS_MAP_JSON}
|
||||||
|
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}
|
||||||
|
NODE_FUNCTION_ALLOW_BUILTIN: crypto
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `PAPERLESS_CORRESPONDENT_SCHOOL` and `PAPERLESS_DOCUMENT_TYPE_SCHOOL` should be numeric IDs if you want those fields auto-assigned.
|
||||||
|
- `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON`, and `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` are optional JSON maps used by workflow 19.
|
||||||
|
- Leave optional Paperless IDs and JSON maps blank/default if you prefer to start with deterministic title + generic tags only.
|
||||||
|
- Workflow 18 v1.1 no longer requires `crypto`, so `NODE_FUNCTION_ALLOW_BUILTIN=crypto` is optional instead of required.
|
||||||
|
- Workflow 18 v1.2 fixes the Paperless multipart upload by using `inputDataFieldName` for the binary form field.
|
||||||
|
- `N8N_BLOCK_ENV_ACCESS_IN_NODE` must remain `false`.
|
||||||
|
|
||||||
|
## Paperless metadata behavior
|
||||||
|
|
||||||
|
The upload uses:
|
||||||
|
- filename: `<intake_id>.<ext>`
|
||||||
|
- title: `<class_name> — <assignment_name> — <submission_kind>`
|
||||||
|
|
||||||
|
Workflow 18 deliberately keeps the Paperless upload minimal and safe:
|
||||||
|
- multipart `document`
|
||||||
|
- deterministic title
|
||||||
|
|
||||||
|
The deterministic filename/title preserve the `intake_id` for later correlation.
|
||||||
|
|
||||||
|
### Workflow 19 — v1.1
|
||||||
|
|
||||||
|
Workflow 19 handles the follow-up metadata pass after Paperless finishes processing:
|
||||||
|
- fetch Paperless document by webhook document id
|
||||||
|
- resolve `intake_id` from the stored filename
|
||||||
|
- look up the intake record in shared Postgres
|
||||||
|
- re-apply deterministic title
|
||||||
|
- merge generic school tags plus optional class/submission-kind mappings
|
||||||
|
- optionally set document type / correspondent from JSON env mappings
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use the example curl request in:
|
||||||
|
- `n8n-workflows/fixtures/school-paperless-intake-webhook.curl.example.txt`
|
||||||
|
|
||||||
|
Minimum test checklist:
|
||||||
|
1. Import the workflow into n8n.
|
||||||
|
2. Attach the `Shared Postgres` credential.
|
||||||
|
3. Send the sample multipart request.
|
||||||
|
4. Verify the uploaded Paperless document title and stored filename both include the deterministic intake ID.
|
||||||
|
5. Trigger workflow 19 with the Paperless document id and verify title/tags enrichment succeeds.
|
||||||
|
6. Re-submit the same file and metadata to confirm the same deterministic `intake_id` pattern is reused.
|
||||||
|
|
||||||
|
## Known assumptions / caveats
|
||||||
|
|
||||||
|
- The workflow is repo-only and was not live-tested against the running n8n/Paperless stack.
|
||||||
|
- The Postgres node uses SQL expressions inline rather than parameter binding because exported n8n node JSON can differ by version.
|
||||||
|
- Workflow 18 v1.0 needed `require('crypto')`; workflow 18 v1.1 removed that dependency for easier live rollout.
|
||||||
|
- If Paperless needs tags as repeated `tags[]` fields instead of a comma-separated `tags` field, adjust the HTTP Request node after import.
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
# Secrets Management
|
# Secrets Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Secrets are managed in two layers:
|
||||||
|
|
||||||
|
1. **Live secrets** — `.env` files on PD at `/mnt/docker-ssd/docker/compose/<stack>/.env`, gitignored and never committed to the main repo
|
||||||
|
2. **Encrypted backup** — all `.env` files are synced to a private git-crypt encrypted Gitea repo at `/mnt/docker-ssd/docker/secrets/`
|
||||||
|
|
||||||
|
The sync is a one-command operation and is safe to re-run at any time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
- `.env` files are gitignored and never committed to any public repo
|
|
||||||
- `.env.example` files are committed with placeholder values
|
- `.env` files are gitignored in the main repo — never committed there
|
||||||
- Real `.env` files live only on the host at the stack directory
|
- `.env.example` files with placeholder values are committed instead
|
||||||
|
- The secrets repo encrypts everything at rest using git-crypt (symmetric key)
|
||||||
- Secrets are generated with standard tools, never reused across services
|
- Secrets are generated with standard tools, never reused across services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Generating Secrets
|
## Generating Secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -19,46 +33,182 @@ openssl rand -hex 32
|
|||||||
openssl rand -base64 32
|
openssl rand -base64 32
|
||||||
```
|
```
|
||||||
|
|
||||||
## .env File Locations
|
---
|
||||||
|
|
||||||
|
## Live .env File Locations
|
||||||
|
|
||||||
All `.env` files live alongside their `docker-compose.yaml`:
|
All `.env` files live alongside their `docker-compose.yaml`:
|
||||||
|
|
||||||
```
|
```
|
||||||
/mnt/docker-ssd/docker/compose/<stack>/.env
|
/mnt/docker-ssd/docker/compose/
|
||||||
|
ai/.env
|
||||||
|
automation/.env
|
||||||
|
databases/.env
|
||||||
|
dev/.env
|
||||||
|
documents/.env
|
||||||
|
home/.env
|
||||||
|
infrastructure/.env
|
||||||
|
media/.env
|
||||||
|
media/kima-hub/.env
|
||||||
|
mesh-mqtt-observer/.env
|
||||||
|
meshtastic/.env
|
||||||
|
monitoring/.env
|
||||||
|
photos/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backing Up .env Files
|
---
|
||||||
|
|
||||||
|
## Secrets Repo (git-crypt)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Gitea repo** | `https://gitea.paccoco.com/fizzlepoof/homelab-secrets` (private) |
|
||||||
|
| **SSH remote** | `ssh://git@10.5.1.6:2222/fizzlepoof/homelab-secrets.git` |
|
||||||
|
| **Local path on PD** | `/mnt/docker-ssd/docker/secrets/` |
|
||||||
|
| **Symmetric key location** | `/mnt/docker-ssd/docker/secrets/.git-crypt-secrets.key` (if copied there) or retrieve from password manager |
|
||||||
|
| **Key backup** | Password manager + `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` |
|
||||||
|
|
||||||
|
### Repo structure
|
||||||
|
|
||||||
|
| Directory | Contents |
|
||||||
|
|-----------|----------|
|
||||||
|
| `env/` | `.env` files for each stack, named `<stack>.env` |
|
||||||
|
| `keys/` | API keys and tokens |
|
||||||
|
| `certs/` | TLS certificates |
|
||||||
|
| `tokens/` | Service tokens (Paperless, LiteLLM, etc.) |
|
||||||
|
| `ssh/` | SSH private keys |
|
||||||
|
|
||||||
|
All files except `README.md` and `.gitattributes` are encrypted by git-crypt on commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Syncing .env Files to the Secrets Repo
|
||||||
|
|
||||||
|
Use the sync script — no root required, safe to re-run:
|
||||||
|
|
||||||
### Short term: Private Gitea repo
|
|
||||||
Create a private repo on Gitea (not GitHub) and push all .env files there:
|
|
||||||
```bash
|
```bash
|
||||||
# In a separate directory, not the main stacks repo
|
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
|
||||||
git init homelab-secrets
|
|
||||||
cd homelab-secrets
|
|
||||||
# Copy and add .env files
|
|
||||||
git remote add origin https://gitea.paccoco.com/fizzlepoof/homelab-secrets.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Long term: Restic + offsite
|
What it does:
|
||||||
Include the compose directory (with .env files) in a restic backup:
|
- Scans all stack directories under `/mnt/docker-ssd/docker/compose/` for `.env` files
|
||||||
|
- Copies changed/new files to `/mnt/docker-ssd/docker/secrets/env/` as `<stack>.env`
|
||||||
|
- Skips unchanged files
|
||||||
|
- Commits and pushes to Gitea automatically
|
||||||
|
|
||||||
|
Run this any time you create or update a stack's `.env` file.
|
||||||
|
|
||||||
|
**Operational rule:** after any live `.env` change on PD, Doris or the operator must run the sync script and confirm whether it committed/pushed changes or reported the secrets repo already up to date. Do not treat live `.env` edits as complete until this sync step is checked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## git-crypt on TrueNAS
|
||||||
|
|
||||||
|
apt is blocked on TrueNAS Scale. git-crypt is installed via Docker.
|
||||||
|
|
||||||
|
### Preferred installation location
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/docker-ssd/bin/git-crypt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibility note
|
||||||
|
|
||||||
|
The original bootstrap script installs to `/root/bin/git-crypt`, while the newer runbook expects `/mnt/docker-ssd/bin/git-crypt`. Standardize on `/mnt/docker-ssd/bin/git-crypt` and make sure PATH includes that directory for both `root` and `truenas_admin`.
|
||||||
|
|
||||||
|
Recommended PATH entry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
restic -r <repo> backup /mnt/docker-ssd/docker/compose --exclude="*.example"
|
export PATH="/mnt/docker-ssd/bin:$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Best long term: Vaultwarden
|
### Re-installing after a full rebuild
|
||||||
Once deployed, store each service's .env as a secure note in Vaultwarden. Single source of truth, accessible from anywhere.
|
|
||||||
|
**Option 1 — one-liner:**
|
||||||
|
```bash
|
||||||
|
sudo -i
|
||||||
|
docker run --rm -v /tmp:/out debian:bookworm-slim \
|
||||||
|
bash -c "apt-get update -qq && apt-get install -y -qq git-crypt && cp /usr/bin/git-crypt /out/git-crypt"
|
||||||
|
mkdir -p /mnt/docker-ssd/bin
|
||||||
|
cp /tmp/git-crypt /mnt/docker-ssd/bin/git-crypt
|
||||||
|
chmod +x /mnt/docker-ssd/bin/git-crypt
|
||||||
|
echo 'export PATH="/mnt/docker-ssd/bin:$PATH"' >> /root/.bashrc
|
||||||
|
echo 'export PATH="/mnt/docker-ssd/bin:$PATH"' >> /home/truenas_admin/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 — via dev stack compose:**
|
||||||
|
```bash
|
||||||
|
cd /mnt/docker-ssd/docker/compose/dev
|
||||||
|
sudo docker compose --profile setup up git-crypt-init
|
||||||
|
# copies git-crypt to /root/bin — then move to persistent location:
|
||||||
|
sudo cp /root/bin/git-crypt /mnt/docker-ssd/bin/git-crypt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unlocking the Secrets Repo on a New Machine
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install git-crypt first (see above), then:
|
||||||
|
git clone ssh://git@10.5.1.6:2222/fizzlepoof/homelab-secrets.git /mnt/docker-ssd/docker/secrets
|
||||||
|
cd /mnt/docker-ssd/docker/secrets
|
||||||
|
git-crypt unlock /path/to/.git-crypt-secrets.key
|
||||||
|
```
|
||||||
|
|
||||||
|
After unlock, the `env/` directory contains plaintext `.env` files ready to copy back into the stack directories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full Rebuild Runbook
|
||||||
|
|
||||||
|
On a fresh PD after reinstalling TrueNAS:
|
||||||
|
|
||||||
|
1. **Install git-crypt** (Option 1 above — docker is available immediately after TrueNAS install)
|
||||||
|
2. **Restore secrets repo:**
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@10.5.1.6:2222/fizzlepoof/homelab-secrets.git /mnt/docker-ssd/docker/secrets
|
||||||
|
cd /mnt/docker-ssd/docker/secrets
|
||||||
|
git-crypt unlock /path/to/.git-crypt-secrets.key
|
||||||
|
```
|
||||||
|
3. **Restore .env files** from `secrets/env/` back to their stack directories:
|
||||||
|
```bash
|
||||||
|
cp /mnt/docker-ssd/docker/secrets/env/ai.env /mnt/docker-ssd/docker/compose/ai/.env
|
||||||
|
cp /mnt/docker-ssd/docker/secrets/env/databases.env /mnt/docker-ssd/docker/compose/databases/.env
|
||||||
|
# ... etc for each stack
|
||||||
|
```
|
||||||
|
4. **Clone the main stacks repo:**
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@10.5.1.6:2222/fizzlepoof/truenas-stacks.git /mnt/docker-ssd/docker/compose
|
||||||
|
```
|
||||||
|
5. **Redeploy stacks** per `DEPLOYMENT_CHECKLIST.md`
|
||||||
|
6. **After any future live `.env` edit**, immediately run:
|
||||||
|
```bash
|
||||||
|
export PATH="/mnt/docker-ssd/bin:$PATH"
|
||||||
|
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
|
||||||
|
```
|
||||||
|
Then verify the secrets repo is either updated and pushed or already unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Known Credentials Locations
|
## Known Credentials Locations
|
||||||
|
|
||||||
| Service | Where stored |
|
| Service | File |
|
||||||
|---------|-------------|
|
|---------|------|
|
||||||
| shared-postgres | `databases/.env` |
|
| shared-postgres | `databases/.env` |
|
||||||
| shared-mariadb | `databases/.env` |
|
| shared-mariadb | `databases/.env` |
|
||||||
| OpenWebUI DB | `ai/.env` — user: openwebui, db: openwebui |
|
| OpenWebUI DB | `ai/.env` — user: openwebui, db: openwebui |
|
||||||
| Donetick | `home/.env` + `selfhosted.yaml` |
|
| Donetick | `home/.env` + `selfhosted.yaml` |
|
||||||
| Meshmonitor | `meshtastic/.env` |
|
| Meshmonitor | `meshtastic/.env` |
|
||||||
| Gitea | `dev/.env` |
|
| Gitea | `dev/.env` |
|
||||||
|
| LiteLLM | `ai/.env` |
|
||||||
|
| Paperless | `documents/.env` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security Reminders
|
## Security Reminders
|
||||||
|
|
||||||
|
- **Key backup**: `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` — also store in password manager
|
||||||
- Regenerate Wings token on N.O.M.A.D. (was exposed in chat — TODO)
|
- Regenerate Wings token on N.O.M.A.D. (was exposed in chat — TODO)
|
||||||
- Regenerate N.O.M.A.D. Newt secret (was exposed in chat — TODO)
|
- Regenerate N.O.M.A.D. Newt secret (was exposed in chat — TODO)
|
||||||
- Disable signups in OpenWebUI after creating initial account
|
- Disable signups in OpenWebUI after creating initial account
|
||||||
|
- The Gitea token in `setup-secrets-repo.sh` should be rotated after initial setup
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
Remotes:
|
Remotes:
|
||||||
- GitHub: `git@github.com:Paccoco/truenas-stacks.git`
|
- GitHub: `git@github.com:Paccoco/truenas-stacks.git`
|
||||||
- Gitea: `https://gitea.paccoco.com/fizzlepoof/truenas-stacks.git`
|
- Gitea web: `http://10.5.1.6:3002/fizzlepoof/truenas-stacks`
|
||||||
|
- Gitea SSH: `ssh://git@10.5.1.6:2222/fizzlepoof/truenas-stacks.git`
|
||||||
|
|
||||||
|
Use the internal Gitea SSH remote from PD/NOMAD. Port `2222` is SSH, not HTTP.
|
||||||
|
|
||||||
## Stack Layout
|
## Stack Layout
|
||||||
Each production stack lives in a subdirectory of the repo root.
|
Each production stack lives in a subdirectory of the repo root.
|
||||||
@@ -16,6 +19,16 @@ Each stack contains:
|
|||||||
- `.env.example` — committed with placeholder values
|
- `.env.example` — committed with placeholder values
|
||||||
- optional init scripts (e.g. `initdb/`, `initdb-mariadb/`)
|
- 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
|
## Deployment Order
|
||||||
1. `databases/` — must be up before anything else
|
1. `databases/` — must be up before anything else
|
||||||
2. `infrastructure/` — newt creates the pangolin network
|
2. `infrastructure/` — newt creates the pangolin network
|
||||||
@@ -116,7 +129,7 @@ After changing a healthcheck, must `down` then `up` (not just restart).
|
|||||||
cd /mnt/docker-ssd/docker/compose
|
cd /mnt/docker-ssd/docker/compose
|
||||||
git add .
|
git add .
|
||||||
git commit -m "description"
|
git commit -m "description"
|
||||||
git push # pushes to both GitHub and Gitea
|
git push ssh://git@10.5.1.6:2222/fizzlepoof/truenas-stacks.git main
|
||||||
```
|
```
|
||||||
|
|
||||||
All `.env` files are gitignored. Only compose files, `.env.example`, and init scripts are committed.
|
All `.env` files are gitignored. Only compose files, `.env.example`, and init scripts are committed.
|
||||||
|
|||||||
@@ -1,91 +1,125 @@
|
|||||||
# Kima-Hub Deployment Plan
|
# Kima-Hub Deployment / Repair Reference
|
||||||
|
|
||||||
**Host:** PlausibleDeniability (PD)
|
**Host:** PlausibleDeniability (PD)
|
||||||
**Port:** 3333
|
**Stack dir in repo:** `media/kima-hub/`
|
||||||
**Stack dir:** `/mnt/docker-ssd/docker/compose/media-extra/`
|
**Live stack dir on host:** `/mnt/docker-ssd/docker/compose/media/kima-hub/`
|
||||||
**Status:** Pending
|
**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)
|
## Canonical live `.env` shape
|
||||||
- [ ] 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`
|
|
||||||
|
|
||||||
## 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
|
```yaml
|
||||||
services:
|
services:
|
||||||
kima-hub:
|
kima:
|
||||||
image: chevron7locked/kima:latest
|
image: ghcr.io/chevron7locked/kima:latest
|
||||||
container_name: kima-hub
|
container_name: kima
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3333:3030"
|
|
||||||
volumes:
|
|
||||||
- /mnt/tank/docker/appdata/kima-hub:/data
|
|
||||||
- /mnt/unraid/data/media/music:/music:ro
|
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
healthcheck:
|
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||||
test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q 0BD6 || exit 1"]
|
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||||
interval: 30s
|
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
|
||||||
timeout: 10s
|
- DISABLE_CLAP=${DISABLE_CLAP:-}
|
||||||
retries: 3
|
- 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:
|
networks:
|
||||||
- pangolin
|
- pangolin
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pangolin:
|
pangolin:
|
||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Internal port is `3030`, mapped to `3333` on the host. Hex `0BD6` = port 3030 for healthcheck.
|
## Validate before deploy
|
||||||
> **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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/docker-ssd/docker/compose/media-extra
|
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||||
sudo docker compose --env-file .env config # validate
|
sudo docker compose --env-file .env config
|
||||||
sudo docker compose --env-file .env up -d
|
|
||||||
sudo docker logs kima-hub --tail 30
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Post-Deploy
|
## Deploy / repair
|
||||||
|
|
||||||
1. Open `http://10.5.1.6:3333` and create your account
|
```bash
|
||||||
2. Point Kima at your music directory (`/music`)
|
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||||
3. In Lidarr: Settings → Connect → add Kima-Hub webhook
|
sudo docker compose --env-file .env up -d
|
||||||
4. In Audiobookshelf: verify Kima can reach it at `http://10.5.1.6:13358`
|
sudo docker compose --env-file .env ps
|
||||||
5. Add Pangolin resource: `kima.paccoco.com` → port 3333
|
sudo docker logs --tail 60 kima
|
||||||
|
```
|
||||||
|
|
||||||
## Pangolin Resource
|
## Post-deploy checks
|
||||||
|
|
||||||
Add in Pangolin dashboard:
|
1. Open `http://10.5.1.6:3333`
|
||||||
- **Subdomain:** `kima.paccoco.com`
|
2. Confirm the app starts and initial setup loads
|
||||||
- **Target:** `http://10.5.1.6:3333`
|
3. Confirm `/music` is visible to Kima
|
||||||
- **Auth:** Enable (personal use only)
|
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 |
|
## Optional integrations / secrets
|
||||||
|------|------|--------|
|
|
||||||
| `/mnt/tank/docker/appdata/kima-hub` | tank | Config, DB, playlists — not write-heavy |
|
Not required just to boot:
|
||||||
| `/mnt/unraid/data/media/music` | Unraid NFS (read-only) | Source library stays on Serenity |
|
|
||||||
|
- **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
|
## Notes
|
||||||
|
|
||||||
- NFS mount must be up before starting (`sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh`)
|
- 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.
|
||||||
- If you transcode on the fly, CPU load on PD will spike; Kima-Hub is CPU-bound for transcoding
|
- KitchenOwl already lives in the `home/` stack; Kima lives in its own `media/kima-hub/` stack.
|
||||||
- Kima-Hub has no GPU acceleration — pure CPU transcoding
|
- Keep compose-managed runtime under `/mnt/docker-ssd/docker/compose/...` per SOP.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Pi-hole Deployment Plan
|
# Pi-hole Deployment Plan
|
||||||
|
|
||||||
**Status:** Pending
|
**Status:** PD primary stack scaffolded repo-side at `pihole/`; live 3-node rollout still pending replica details and PD-side deployment verification
|
||||||
**Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync
|
**Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
│ 10.5.1.53/24 │
|
│ 10.5.1.53/24 │
|
||||||
└──────┬──────────────┬──────────────────┘
|
└──────┬──────────────┬──────────────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
PD (MASTER) Serenity (BACKUP1) RPi4 (BACKUP2)
|
PD (MASTER) N.O.M.A.D. (BACKUP1) RPi4 (BACKUP2)
|
||||||
10.5.1.6 10.5.1.5 10.5.1.X
|
10.5.1.6 10.5.1.16 10.5.1.X
|
||||||
priority 100 priority 90 priority 80
|
priority 100 priority 90 priority 80
|
||||||
Pi-hole :53 Pi-hole :53 Pi-hole :53
|
Pi-hole :53 Pi-hole :53 Pi-hole :53
|
||||||
Unbound :5335 Unbound :5335 Unbound :5335
|
Unbound :5335 Unbound :5335 Unbound :5335
|
||||||
@@ -108,7 +108,8 @@ mkdir -p /path/to/appdata/unbound/var
|
|||||||
|
|
||||||
## Node 1: PlausibleDeniability (MASTER)
|
## Node 1: PlausibleDeniability (MASTER)
|
||||||
|
|
||||||
**Stack dir:** `/mnt/docker-ssd/docker/compose/pihole/`
|
**Repo stack dir:** `/home/fizzlepoof/repos/truenas-stacks/pihole/`
|
||||||
|
**Live stack dir:** `/mnt/docker-ssd/docker/compose/pihole/`
|
||||||
**Web UI port:** `8953`
|
**Web UI port:** `8953`
|
||||||
|
|
||||||
### Pre-Deploy
|
### Pre-Deploy
|
||||||
@@ -210,23 +211,25 @@ LAN_INTERFACE=eno1
|
|||||||
|
|
||||||
### Deploy
|
### Deploy
|
||||||
|
|
||||||
|
Preferred PD-first flow now that the repo-side stack exists:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/docker-ssd/docker/compose/pihole
|
cd /mnt/docker-ssd/docker/compose/pihole
|
||||||
# Copy unbound.conf to appdata
|
cp .env.example .env
|
||||||
sudo cp unbound.conf /mnt/tank/docker/appdata/unbound-primary/
|
nano .env
|
||||||
sudo docker compose --env-file .env config
|
/usr/bin/bash bin/prepare_pd.sh
|
||||||
sudo docker compose --env-file .env up -d
|
/usr/bin/bash bin/prepare_pd.sh --up
|
||||||
sudo docker logs pihole --tail 20
|
sudo docker logs pihole-primary --tail 20
|
||||||
sudo docker logs unbound --tail 20
|
sudo docker logs unbound-pihole-primary --tail 20
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Node 2: Serenity (BACKUP1)
|
## Node 2: N.O.M.A.D. (BACKUP1)
|
||||||
|
|
||||||
Serenity runs Unraid. Deploy via Docker Compose in Unraid's **User Scripts** or the **Compose Manager** plugin.
|
N.O.M.A.D. runs Ubuntu bare metal. Deploy via Docker Compose on the host.
|
||||||
|
|
||||||
**Appdata:** `/mnt/user/appdata/`
|
**Appdata:** `/mnt/docker-ssd/docker/appdata/`
|
||||||
**Web UI port:** `8954`
|
**Web UI port:** `8954`
|
||||||
|
|
||||||
### docker-compose.yaml
|
### docker-compose.yaml
|
||||||
@@ -241,7 +244,7 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/unbound-pihole:/opt/unbound/etc/unbound/
|
- /mnt/docker-ssd/docker/appdata/unbound-nomad:/opt/unbound/etc/unbound/
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -250,7 +253,7 @@ services:
|
|||||||
|
|
||||||
pihole:
|
pihole:
|
||||||
image: pihole/pihole:latest
|
image: pihole/pihole:latest
|
||||||
container_name: pihole-secondary
|
container_name: pihole-nomad
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -262,8 +265,11 @@ services:
|
|||||||
FTLCONF_dns_listeningMode: "LOCAL"
|
FTLCONF_dns_listeningMode: "LOCAL"
|
||||||
FTLCONF_dns_upstreams: "127.0.0.1#5335"
|
FTLCONF_dns_upstreams: "127.0.0.1#5335"
|
||||||
FTLCONF_webserver_port: "8954"
|
FTLCONF_webserver_port: "8954"
|
||||||
|
FTLCONF_ntp_ipv4_active: "false"
|
||||||
|
FTLCONF_ntp_ipv6_active: "false"
|
||||||
|
FTLCONF_ntp_sync_active: "false"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/pihole-secondary/etc-pihole:/etc/pihole
|
- /mnt/docker-ssd/docker/appdata/pihole-nomad/etc-pihole:/etc/pihole
|
||||||
cap_add:
|
cap_add:
|
||||||
- SYS_NICE
|
- SYS_NICE
|
||||||
|
|
||||||
@@ -280,16 +286,16 @@ services:
|
|||||||
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
|
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
### keepalived.conf (Serenity — BACKUP1)
|
### keepalived.conf (N.O.M.A.D. — BACKUP1)
|
||||||
|
|
||||||
```
|
```
|
||||||
global_defs {
|
global_defs {
|
||||||
router_id pihole_serenity
|
router_id pihole_nomad
|
||||||
}
|
}
|
||||||
|
|
||||||
vrrp_instance VI_DNS {
|
vrrp_instance VI_DNS {
|
||||||
state BACKUP
|
state BACKUP
|
||||||
interface eth0
|
interface enp5s0
|
||||||
virtual_router_id 53
|
virtual_router_id 53
|
||||||
priority 90
|
priority 90
|
||||||
advert_int 1
|
advert_int 1
|
||||||
@@ -411,7 +417,7 @@ sudo systemctl start keepalived
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PRIMARY: "http://127.0.0.1:8953|${PIHOLE_PASSWORD}"
|
PRIMARY: "http://127.0.0.1:8953|${PIHOLE_PASSWORD}"
|
||||||
REPLICAS: "http://10.5.1.5:8954|${PIHOLE_PASSWORD_SERENITY},http://10.5.1.X:8955|${PIHOLE_PASSWORD_RPI}"
|
REPLICAS: "http://10.5.1.16:8954|${PIHOLE_PASSWORD_NOMAD},http://10.5.1.X:8955|${PIHOLE_PASSWORD_RPI}"
|
||||||
RUN_GRAVITY: "true"
|
RUN_GRAVITY: "true"
|
||||||
FULL_SYNC: "false"
|
FULL_SYNC: "false"
|
||||||
CRON: "0 3 * * *"
|
CRON: "0 3 * * *"
|
||||||
@@ -423,7 +429,7 @@ sudo systemctl start keepalived
|
|||||||
> - `REPLICAS` — comma-separated list of secondary instances
|
> - `REPLICAS` — comma-separated list of secondary instances
|
||||||
> - `CRON` — syncs daily at 3 AM; force a manual sync with `docker exec nebula-sync nebula-sync run`
|
> - `CRON` — syncs daily at 3 AM; force a manual sync with `docker exec nebula-sync nebula-sync run`
|
||||||
> - Each Pi-hole instance can (and should) have a **different** password — nebula-sync handles auth per-instance
|
> - Each Pi-hole instance can (and should) have a **different** password — nebula-sync handles auth per-instance
|
||||||
> - Add `PIHOLE_PASSWORD_SERENITY` and `PIHOLE_PASSWORD_RPI` to your `.env` file
|
> - Add `PIHOLE_PASSWORD_NOMAD` and `PIHOLE_PASSWORD_RPI` to your `.env` file
|
||||||
|
|
||||||
### What Nebula Sync copies
|
### What Nebula Sync copies
|
||||||
- Gravity database (blocklists, allowlists, group assignments)
|
- Gravity database (blocklists, allowlists, group assignments)
|
||||||
@@ -466,7 +472,7 @@ nslookup doubleclick.net 10.5.1.53 # Should return 0.0.0.0 (blocked)
|
|||||||
# On PD, bring down keepalived
|
# On PD, bring down keepalived
|
||||||
sudo docker stop keepalived
|
sudo docker stop keepalived
|
||||||
# Wait ~3 seconds, then check from another machine:
|
# Wait ~3 seconds, then check from another machine:
|
||||||
nslookup google.com 10.5.1.53 # Should still work — Serenity took over VIP
|
nslookup google.com 10.5.1.53 # Should still work — N.O.M.A.D. took over VIP
|
||||||
# Restore
|
# Restore
|
||||||
sudo docker start keepalived
|
sudo docker start keepalived
|
||||||
```
|
```
|
||||||
@@ -488,7 +494,6 @@ Add in Pi-hole (primary) → Local DNS → DNS Records:
|
|||||||
| Domain | IP |
|
| Domain | IP |
|
||||||
|--------|----|
|
|--------|----|
|
||||||
| pd.lan | 10.5.1.6 |
|
| pd.lan | 10.5.1.6 |
|
||||||
| serenity.lan | 10.5.1.5 |
|
|
||||||
| nomad.lan | 10.5.1.16 |
|
| nomad.lan | 10.5.1.16 |
|
||||||
| rocinante.lan | 10.5.1.112 |
|
| rocinante.lan | 10.5.1.112 |
|
||||||
| rpi4.lan | 10.5.1.X |
|
| rpi4.lan | 10.5.1.X |
|
||||||
@@ -500,7 +505,7 @@ Nebula Sync will propagate these to all replicas.
|
|||||||
| Subdomain | Target |
|
| Subdomain | Target |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
| `pihole.paccoco.com` | `http://10.5.1.6:8953` (primary) |
|
| `pihole.paccoco.com` | `http://10.5.1.6:8953` (primary) |
|
||||||
| `pihole2.paccoco.com` | `http://10.5.1.5:8954` (Serenity) |
|
| `pihole2.paccoco.com` | `http://10.5.1.16:8954` (N.O.M.A.D.) |
|
||||||
|
|
||||||
Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
|
Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
|
||||||
|
|
||||||
@@ -512,7 +517,7 @@ Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
|
|||||||
|------|------|------|--------|
|
|------|------|------|--------|
|
||||||
| PD | `/mnt/tank/docker/appdata/pihole-primary` | tank | Low write frequency |
|
| PD | `/mnt/tank/docker/appdata/pihole-primary` | tank | Low write frequency |
|
||||||
| PD | `/mnt/tank/docker/appdata/unbound-primary` | tank | Config + root hints only |
|
| PD | `/mnt/tank/docker/appdata/unbound-primary` | tank | Config + root hints only |
|
||||||
| Serenity | `/mnt/user/appdata/pihole-secondary` | Unraid array | Low write frequency |
|
| N.O.M.A.D. | `/mnt/docker-ssd/docker/appdata/pihole-nomad` | Local SSD | Docker-managed local appdata |
|
||||||
| RPi4 | `~/pihole/` | SD card / SSD | Dedicated Pi, no other concerns |
|
| RPi4 | `~/pihole/` | SD card / SSD | Dedicated Pi, no other concerns |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
82
docs/planning/SCHOOL_INTAKE_PIPELINE.md
Normal file
82
docs/planning/SCHOOL_INTAKE_PIPELINE.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# School Work Intake Pipeline (Telegram → Postgres → Paperless → n8n)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give Doris a reliable way to ingest school work from Telegram while preserving explicit metadata:
|
||||||
|
- class
|
||||||
|
- assignment
|
||||||
|
- submission kind (`draft`, `final`, etc.)
|
||||||
|
- optional semester / instructor / due date / paper kind
|
||||||
|
|
||||||
|
The design must be expandable for new classes and future non-paper workflows.
|
||||||
|
|
||||||
|
## Recommended architecture
|
||||||
|
|
||||||
|
1. **Telegram chat-driven intake**
|
||||||
|
- John sends file(s) to Doris on Telegram.
|
||||||
|
- Doris asks follow-up questions until required metadata is complete.
|
||||||
|
|
||||||
|
2. **n8n intake webhook**
|
||||||
|
- Doris submits the file + metadata to an intake webhook.
|
||||||
|
- The webhook creates a durable intake record in shared Postgres.
|
||||||
|
- The webhook stages/uploads the file into Paperless with a deterministic `intake_id` embedded in the filename.
|
||||||
|
|
||||||
|
3. **Paperless post-consumption webhook**
|
||||||
|
- After Paperless finishes processing the file, a second workflow fetches the Paperless document.
|
||||||
|
- That workflow extracts `intake_id`, looks up the Postgres intake record, and applies metadata.
|
||||||
|
|
||||||
|
4. **Metadata + AI enrichment**
|
||||||
|
- Deterministic metadata first: class tags, version tags, title rules, optional correspondent/document type IDs.
|
||||||
|
- AI second: document summary/note and any low-risk enrichment.
|
||||||
|
- Ambiguous cases can be routed to a review queue.
|
||||||
|
|
||||||
|
## Why this is better than folder-based consume ingestion
|
||||||
|
|
||||||
|
Folder paths are a decent hint, but they are not durable enough to be the only source of truth.
|
||||||
|
|
||||||
|
Problems with folder-only inference:
|
||||||
|
- folder context can disappear after import
|
||||||
|
- mixed uploads are easy to mislabel
|
||||||
|
- retries/reprocessing can grab the wrong document
|
||||||
|
- future use-cases (draft review, assignment history, instructor-specific rules) need explicit metadata anyway
|
||||||
|
|
||||||
|
## Shared DB requirements
|
||||||
|
|
||||||
|
Use shared Postgres, not SQLite.
|
||||||
|
|
||||||
|
Schema artifact:
|
||||||
|
- `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`
|
||||||
|
|
||||||
|
Core tables:
|
||||||
|
- `school_paperless_intake`
|
||||||
|
- `school_paperless_intake_events`
|
||||||
|
|
||||||
|
## Config-driven class profiles
|
||||||
|
|
||||||
|
Class/course behavior should live in config, not hardcoded workflow branches.
|
||||||
|
|
||||||
|
Suggested profile fields:
|
||||||
|
- `class_key`
|
||||||
|
- `display_name`
|
||||||
|
- `course_code`
|
||||||
|
- `default_tags`
|
||||||
|
- Paperless IDs for correspondent/document type/storage path when needed
|
||||||
|
- title template override
|
||||||
|
|
||||||
|
## Initial rollout target
|
||||||
|
|
||||||
|
- ENGL-1010
|
||||||
|
- HIST-2020
|
||||||
|
|
||||||
|
## OpenClaw helper artifact
|
||||||
|
|
||||||
|
Local helper:
|
||||||
|
- `school/intake/submit_to_n8n.js`
|
||||||
|
|
||||||
|
This gives Doris a simple handoff point after the Telegram conversation is complete.
|
||||||
|
|
||||||
|
## Live work still needed
|
||||||
|
|
||||||
|
- configure class profiles with real Paperless IDs / tag mappings
|
||||||
|
- decide Telegram delivery wording/confirmation behavior
|
||||||
|
- after any live `.env` changes for this pipeline, sync them into the encrypted homelab secrets repo (`docs/operations/SECRETS_MANAGEMENT.md`)
|
||||||
@@ -1,27 +1,28 @@
|
|||||||
# Homelab TODO
|
# Homelab TODO
|
||||||
|
|
||||||
## Security (do ASAP)
|
## Security (do ASAP)
|
||||||
- [ ] Regenerate Wings token on N.O.M.A.D. (was exposed in chat)
|
- [x] Regenerate Wings token on N.O.M.A.D. (was exposed in chat) — completed 2026-05-15
|
||||||
- [ ] Regenerate N.O.M.A.D. Newt secret (was exposed in chat)
|
- [x] Regenerate N.O.M.A.D. Newt secret (was exposed in chat) — completed 2026-05-15
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
- [ ] Deploy Pi-hole 3-node HA cluster (PD + Serenity + RPi4) — see docs/planning/DEPLOY_PIHOLE.md
|
- [ ] Deploy Pi-hole 3-node HA cluster (PD + N.O.M.A.D. + RPi4) — see docs/planning/DEPLOY_PIHOLE.md
|
||||||
|
- PD primary stack scaffolded repo-side under `pihole/` on 2026-05-15
|
||||||
- RPi4 IP still TBD — fill in before deploying
|
- RPi4 IP still TBD — fill in before deploying
|
||||||
- [ ] Deploy Kima-Hub — see docs/planning/DEPLOY_KIMA_HUB.md
|
- [x] Deploy Kima-Hub — completed 2026-05-06; docs/planning/DEPLOY_KIMA_HUB.md now serves as deployment/repair reference
|
||||||
- [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos)
|
- [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos)
|
||||||
- [ ] Remove dead Unraid-Cloudflared-Tunnel container from Serenity
|
- [ ] Remove dead Unraid-Cloudflared-Tunnel container from Serenity
|
||||||
- [ ] Serenity malcolm pool capacity planning (heavily utilized)
|
- [ ] Serenity malcolm pool capacity planning (heavily utilized)
|
||||||
- [ ] Add Pangolin resource for `ai.paccoco.com` → OpenWebUI (port 8282)
|
- [x] Add Pangolin resource for `openwebui.paccoco.com` → OpenWebUI (port 8282) — completed 2026-05-15
|
||||||
- [ ] Add Cloudflare DNS A record for `ai.paccoco.com`
|
- [x] Add Cloudflare DNS record for `openwebui.paccoco.com` — completed 2026-05-15
|
||||||
- [ ] Install fresh editor on PD
|
- [ ] Install fresh editor on PD
|
||||||
- [ ] Set up private Gitea repo for .env file backups
|
- [x] Set up private Gitea repo for .env file backups — 2026-05-14
|
||||||
|
|
||||||
## AI Stack
|
## AI Stack
|
||||||
- [ ] Pull `qwen2.5:14b` on PD ollama: `sudo docker exec -it ollama ollama pull qwen2.5:14b`
|
- [x] Pull `qwen2.5:14b` on PD ollama — completed 2026-05-15
|
||||||
- [ ] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md
|
- [x] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md — completed 2026-05-15
|
||||||
- [ ] Disable signups in OpenWebUI after creating account
|
- [x] Disable signups in OpenWebUI after creating account — completed 2026-05-15
|
||||||
- [ ] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections)
|
- [x] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections) — completed 2026-05-15
|
||||||
- [ ] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted
|
- [x] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted — completed 2026-05-15
|
||||||
|
|
||||||
## Healthchecks
|
## Healthchecks
|
||||||
- [ ] Add healthcheck to ix-plex-plex-1
|
- [ ] Add healthcheck to ix-plex-plex-1
|
||||||
@@ -32,15 +33,23 @@
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
- [ ] Document meshtastic stack fully (see docs/reference/MESHTASTIC.md)
|
- [ ] Document meshtastic stack fully (see docs/reference/MESHTASTIC.md)
|
||||||
- [ ] Document N.O.M.A.D. containers (start_nomad.sh services)
|
- [x] Document N.O.M.A.D. Docker/container audit (compose-managed, Project N.O.M.A.D.-managed, unmanaged, and Pelican/Wings-managed containers) — 2026-05-14
|
||||||
|
- [x] Decide which services should run on NOMAD, which should not, and whether each should live under raw compose, Project N.O.M.A.D.-managed state, systemd, or another more explicit deployment model — 2026-05-14
|
||||||
|
- Project N.O.M.A.D.-managed app containers are explicitly hands-off and stay exactly as-is.
|
||||||
|
- `whisper` and `node-exporter` may be removed; exact runtime was documented in `docs/servers/NOMAD.md` for emergency rebuild.
|
||||||
|
- Pelican/Wings-managed game containers stay and are documented as permanent game-server infrastructure on NOMAD.
|
||||||
|
|
||||||
## n8n Workflows
|
## n8n Workflows
|
||||||
- [ ] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra
|
- [ ] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra
|
||||||
- [ ] Add env vars to n8n: `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_TRANSCRIPTION`, `PAPERLESS_TAG_LECTURE_NOTES`, `N8N_API_KEY`
|
- [ ] Add remaining env vars to n8n docker-compose + .env: `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_TRANSCRIPTION`, `PAPERLESS_TAG_LECTURE_NOTES`, `N8N_API_KEY` (see n8n-workflows/README.md for docker-compose.yaml changes required)
|
||||||
- [ ] Set up `PD SSH` credential in n8n for workflow 16 (see n8n-workflows/README.md)
|
- [ ] Set up `PD SSH` credential in n8n for workflow 16 (see n8n-workflows/README.md)
|
||||||
- [ ] Add sudoers NOPASSWD rule for n8n watchdog (see n8n-workflows/README.md)
|
- [ ] Add sudoers NOPASSWD rule for n8n watchdog (see n8n-workflows/README.md)
|
||||||
- [ ] Import and activate upgraded workflows: 01, 04, 08
|
- [x] Import and activate upgraded workflows: 04, 08 (01 done — v1.4 active and tested) — completed 2026-05-15
|
||||||
- [ ] Import and activate new workflows: 14, 15 (16 already active)
|
- [x] Import and activate new workflows: 14, 15 (16 already active) — completed 2026-05-15
|
||||||
|
- [x] Configure class profiles + Paperless metadata mapping for current courses (ENGL-1010, HIST-2020, future classes) — completed 2026-05-15
|
||||||
|
- [x] Delete legacy duplicate Paperless notes for school docs 42–49 after approval / with proper token context — completed 2026-05-14; docs now each have one canonical note
|
||||||
|
- [x] Set up DB and Docker backups to Serenity following homelab SOP — completed 2026-05-15
|
||||||
|
- Live `.env`, SSH trust, scheduler deployment, first run, and verification completed on PD
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
- [x] Deploy Gotify (Phase 1) — 2026-05-05
|
- [x] Deploy Gotify (Phase 1) — 2026-05-05
|
||||||
@@ -56,4 +65,8 @@
|
|||||||
- [x] Write n8n workflow upgrade + new workflow plans (01, 04, 08, 14, 15, 16) — 2026-05-09
|
- [x] Write n8n workflow upgrade + new workflow plans (01, 04, 08, 14, 15, 16) — 2026-05-09
|
||||||
- [x] Write Pi-hole 3-node HA deployment plan — 2026-05-09
|
- [x] Write Pi-hole 3-node HA deployment plan — 2026-05-09
|
||||||
- [x] Write Kima-Hub deployment plan — 2026-05-09
|
- [x] Write Kima-Hub deployment plan — 2026-05-09
|
||||||
- [x] Create Postgres dedup tables (grafana_alert_dedup, n8n_health_dedup) on PD — 2026-05-09
|
- [x] Create Postgres dedup tables (grafana_alert_dedup, n8n_health_dedup) in n8n_rss DB on PD — 2026-05-09
|
||||||
|
- [x] Workflow 01 fully tested end-to-end (dedup, LiteLLM, Gotify) — v1.4 active — 2026-05-09
|
||||||
|
- [x] Add LITELLM_API_KEY + N8N_BLOCK_ENV_ACCESS_IN_NODE to n8n docker-compose — 2026-05-09
|
||||||
|
- [x] Build Telegram chat-driven school-work intake for Paperless using shared Postgres, deterministic `intake_id`, and class/version metadata — 2026-05-14
|
||||||
|
- [x] Build Doris-managed KitchenOwl recipe importer flow with native-scrape fallback, schema.org JSON-LD parsing, duplicate checks, and dry-run support — 2026-05-15
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ See the individual docs in this repo for detailed information:
|
|||||||
| Server | Role | OS | IP |
|
| Server | Role | OS | IP |
|
||||||
|--------|------|----|----|
|
|--------|------|----|----|
|
||||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
||||||
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | LAN DHCP |
|
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | 10.5.1.6 |
|
||||||
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
||||||
|
|
||||||
### Deployment Pattern (PD)
|
### Deployment Pattern (PD)
|
||||||
|
|||||||
47
docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql
Normal file
47
docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Shared Postgres schema for Telegram-driven school-work intake.
|
||||||
|
-- Apply in a dedicated `school_intake` database or adapt to an existing shared DB.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS school_paperless_intake (
|
||||||
|
intake_id TEXT PRIMARY KEY,
|
||||||
|
class_name TEXT NOT NULL,
|
||||||
|
assignment_name TEXT NOT NULL,
|
||||||
|
submission_kind TEXT NOT NULL,
|
||||||
|
semester TEXT,
|
||||||
|
course_code TEXT,
|
||||||
|
paper_kind TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'telegram',
|
||||||
|
telegram_chat_id TEXT,
|
||||||
|
telegram_message_id TEXT,
|
||||||
|
original_filename TEXT NOT NULL,
|
||||||
|
stored_filename TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
file_size_bytes BIGINT NOT NULL,
|
||||||
|
checksum_sha256 TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'received',
|
||||||
|
paperless_task_id TEXT,
|
||||||
|
paperless_document_id BIGINT,
|
||||||
|
paperless_title TEXT,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
uploaded_at TIMESTAMPTZ,
|
||||||
|
enriched_at TIMESTAMPTZ,
|
||||||
|
error_text TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_status ON school_paperless_intake(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_doc_id ON school_paperless_intake(paperless_document_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_assignment ON school_paperless_intake(class_name, assignment_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_checksum ON school_paperless_intake(checksum_sha256);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS school_paperless_intake_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
intake_id TEXT NOT NULL REFERENCES school_paperless_intake(intake_id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_events_intake_id
|
||||||
|
ON school_paperless_intake_events(intake_id);
|
||||||
@@ -21,10 +21,213 @@ Offline survival/knowledge server and game server host.
|
|||||||
| Seagate SSD (sdd) | 223.6GB | `/mnt/ssd-1` | Fast scratch |
|
| Seagate SSD (sdd) | 223.6GB | `/mnt/ssd-1` | Fast scratch |
|
||||||
|
|
||||||
## Project N.O.M.A.D.
|
## Project N.O.M.A.D.
|
||||||
- 10 containers running via `start_nomad.sh` / `stop_nomad.sh`
|
- Core compose project lives at `/opt/project-nomad` → symlinked from `/mnt/hdd-1/project-nomad`
|
||||||
- `/opt/project-nomad` → symlinked from `/mnt/hdd-1/project-nomad`
|
- Main compose file: `/mnt/hdd-1/project-nomad/compose.yml`
|
||||||
- Docker data-root: `/mnt/hdd-2/docker`
|
- Docker data-root: `/mnt/hdd-2/docker`
|
||||||
- Based on Crosstalk Solutions offline survival/knowledge server
|
- Based on Crosstalk Solutions offline survival/knowledge server
|
||||||
|
- Current runtime is split between:
|
||||||
|
- the base `project-nomad` compose stack
|
||||||
|
- `project-nomad-managed` app containers launched/managed by the N.O.M.A.D. admin layer
|
||||||
|
- a few extra non-compose / non-NOMAD containers on the host
|
||||||
|
|
||||||
|
## Docker Audit — 2026-05-14
|
||||||
|
|
||||||
|
### Compose files found on NOMAD
|
||||||
|
- `/mnt/hdd-1/project-nomad/compose.yml` (same project as `/opt/project-nomad/compose.yml` via symlink)
|
||||||
|
- `/opt/newt/docker-compose.yaml`
|
||||||
|
|
||||||
|
### Running containers tied directly to compose files
|
||||||
|
**`project-nomad` compose (`/mnt/hdd-1/project-nomad/compose.yml`)**
|
||||||
|
- `nomad_admin`
|
||||||
|
- `nomad_mysql`
|
||||||
|
- `nomad_redis`
|
||||||
|
- `nomad_dozzle`
|
||||||
|
- `nomad_disk_collector`
|
||||||
|
- `nomad_updater`
|
||||||
|
|
||||||
|
**`newt` compose (`/opt/newt/docker-compose.yaml`)**
|
||||||
|
- `newt`
|
||||||
|
|
||||||
|
### Running containers managed by Project N.O.M.A.D. itself
|
||||||
|
These are labeled `com.docker.compose.project=project-nomad-managed` and `io.project-nomad.managed=true`, but no standalone compose files for them were found on disk during this audit.
|
||||||
|
- `nomad_cyberchef`
|
||||||
|
- `nomad_flatnotes`
|
||||||
|
- `nomad_kiwix_server`
|
||||||
|
- `nomad_kolibri`
|
||||||
|
- `nomad_ollama`
|
||||||
|
- `nomad_qdrant`
|
||||||
|
|
||||||
|
**Operator decision (John, 2026-05-14):** this layer stays exactly as-is. It is self-maintained by Project N.O.M.A.D. Document it, but **never manually touch, rebuild, migrate, or “clean up” these containers** unless John explicitly overrides that rule.
|
||||||
|
|
||||||
|
Known storage paths discovered from mounts / project storage:
|
||||||
|
- Flatnotes data: `/opt/project-nomad/storage/flatnotes`
|
||||||
|
- Kiwix ZIM library: `/opt/project-nomad/storage/zim`
|
||||||
|
- Kolibri data: `/opt/project-nomad/storage/kolibri`
|
||||||
|
- Ollama data/models: `/opt/project-nomad/storage/ollama`
|
||||||
|
- Qdrant storage: `/opt/project-nomad/storage/qdrant`
|
||||||
|
|
||||||
|
### Running containers not tied to discovered compose files
|
||||||
|
**Likely host-managed / manually launched / app-managed elsewhere**
|
||||||
|
|
||||||
|
#### `whisper` — `fedirz/faster-whisper-server:latest-cpu`
|
||||||
|
**Operator decision (John, 2026-05-14):** this can go away. Do not preserve it as a required service, but keep enough detail here to rebuild it if removing it causes issues.
|
||||||
|
- Purpose: ad-hoc Faster Whisper API endpoint
|
||||||
|
- Network mode: `bridge`
|
||||||
|
- Container IP during audit: `172.17.0.2`
|
||||||
|
- Port publish: host `8786/tcp` → container `8000/tcp` (IPv4 and IPv6)
|
||||||
|
- Restart policy: `unless-stopped`
|
||||||
|
- Entrypoint: image default
|
||||||
|
- Command:
|
||||||
|
- `uv run uvicorn --factory faster_whisper_server.main:create_app`
|
||||||
|
- Environment:
|
||||||
|
- `WHISPER__MODEL=Systran/faster-distil-whisper-small.en`
|
||||||
|
- `WHISPER__INFERENCE_DEVICE=cpu`
|
||||||
|
- `UVICORN_HOST=0.0.0.0`
|
||||||
|
- `UVICORN_PORT=8000`
|
||||||
|
- Mounts: none
|
||||||
|
- Compose labels: none found
|
||||||
|
- Rebuild-equivalent `docker run`:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name whisper \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8786:8000 \
|
||||||
|
-e WHISPER__MODEL=Systran/faster-distil-whisper-small.en \
|
||||||
|
-e WHISPER__INFERENCE_DEVICE=cpu \
|
||||||
|
-e UVICORN_HOST=0.0.0.0 \
|
||||||
|
-e UVICORN_PORT=8000 \
|
||||||
|
fedirz/faster-whisper-server:latest-cpu \
|
||||||
|
uv run uvicorn --factory faster_whisper_server.main:create_app
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `node-exporter` — `prom/node-exporter:v1.9.0`
|
||||||
|
**Operator decision (John, 2026-05-14):** this can go away. Do not preserve it as a required service, but keep enough detail here to rebuild it if removing it causes issues.
|
||||||
|
- Purpose: host Prometheus metrics exporter
|
||||||
|
- Network mode: `host`
|
||||||
|
- Restart policy: `unless-stopped`
|
||||||
|
- Entrypoint: `/bin/node_exporter`
|
||||||
|
- Command:
|
||||||
|
- `--path.procfs=/host/proc`
|
||||||
|
- `--path.sysfs=/host/sys`
|
||||||
|
- `--path.rootfs=/rootfs`
|
||||||
|
- `--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)`
|
||||||
|
- Read-only bind mounts:
|
||||||
|
- `/proc` → `/host/proc`
|
||||||
|
- `/sys` → `/host/sys`
|
||||||
|
- `/` → `/rootfs`
|
||||||
|
- Compose labels: none found
|
||||||
|
- Rebuild-equivalent `docker run`:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name node-exporter \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network host \
|
||||||
|
-v /proc:/host/proc:ro \
|
||||||
|
-v /sys:/host/sys:ro \
|
||||||
|
-v /:/rootfs:ro \
|
||||||
|
prom/node-exporter:v1.9.0 \
|
||||||
|
--path.procfs=/host/proc \
|
||||||
|
--path.sysfs=/host/sys \
|
||||||
|
--path.rootfs=/rootfs \
|
||||||
|
'--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pelican/Wings-managed game containers**
|
||||||
|
**Operator decision (John, 2026-05-14):** this is the game server stack. It stays. Document it; do not remove or migrate it unless John explicitly says so.
|
||||||
|
|
||||||
|
- `11d799cb-487a-416a-b6f9-9bf28f0e57e7`
|
||||||
|
- Image: `ghcr.io/pelican-eggs/yolks:java_25`
|
||||||
|
- Network: `pelican_nw` (`172.20.0.2` during audit)
|
||||||
|
- Ports: `25565/tcp` and `25565/udp` exposed on host `0.0.0.0`
|
||||||
|
- Restart policy: `no` (Wings owns lifecycle)
|
||||||
|
- Entrypoint: `/usr/bin/tini -g --`
|
||||||
|
- Command: `/entrypoint.sh`
|
||||||
|
- Important env:
|
||||||
|
- `SERVER_IP=0.0.0.0`
|
||||||
|
- `SERVER_PORT=25565`
|
||||||
|
- `SERVER_JARFILE=server.jar`
|
||||||
|
- `STARTUP=java --add-modules=jdk.incubator.vector -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -jar server.jar`
|
||||||
|
- `P_SERVER_UUID=11d799cb-487a-416a-b6f9-9bf28f0e57e7`
|
||||||
|
- Mounts:
|
||||||
|
- `/var/lib/pelican/volumes/11d799cb-487a-416a-b6f9-9bf28f0e57e7` → `/home/container` (rw)
|
||||||
|
- `/etc/pelican/passwd` → `/etc/passwd` (ro)
|
||||||
|
- `/etc/pelican/group` → `/etc/group` (ro)
|
||||||
|
- `/etc/pelican/machine-id/11d799cb-487a-416a-b6f9-9bf28f0e57e7` → `/etc/machine-id` (ro)
|
||||||
|
|
||||||
|
- `d2aca31e-0d4d-433a-8295-cfad397dab0c`
|
||||||
|
- Image: `ghcr.io/pelican-eggs/yolks:java_21`
|
||||||
|
- Network: `pelican_nw` (`172.20.0.3` during audit)
|
||||||
|
- Ports: `25566/tcp` and `25566/udp` exposed on host `0.0.0.0`
|
||||||
|
- Restart policy: `no` (Wings owns lifecycle)
|
||||||
|
- Entrypoint: `/usr/bin/tini -g --`
|
||||||
|
- Command: `/entrypoint.sh`
|
||||||
|
- Important env:
|
||||||
|
- `SERVER_IP=0.0.0.0`
|
||||||
|
- `SERVER_PORT=25566`
|
||||||
|
- `SERVER_MEMORY=8192`
|
||||||
|
- `MC_VERSION=1.21.1`
|
||||||
|
- `NEOFORGE_VERSION=21.1.229`
|
||||||
|
- `STARTUP=java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true @unix_args.txt`
|
||||||
|
- `P_SERVER_UUID=d2aca31e-0d4d-433a-8295-cfad397dab0c`
|
||||||
|
- Mounts:
|
||||||
|
- `/var/lib/pelican/volumes/d2aca31e-0d4d-433a-8295-cfad397dab0c` → `/home/container` (rw)
|
||||||
|
- `/etc/pelican/passwd` → `/etc/passwd` (ro)
|
||||||
|
- `/etc/pelican/group` → `/etc/group` (ro)
|
||||||
|
- `/etc/pelican/machine-id/d2aca31e-0d4d-433a-8295-cfad397dab0c` → `/etc/machine-id` (ro)
|
||||||
|
|
||||||
|
These are expected to be managed by Wings/Pelican rather than a local compose file.
|
||||||
|
|
||||||
|
### Dormant compose files found but not currently represented in running containers
|
||||||
|
- None found during this audit in searched paths.
|
||||||
|
- Search scope: `/opt`, `/srv`, `/home/fizzlepoof`, and `/mnt` for `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, and `compose.yaml`.
|
||||||
|
|
||||||
|
### Decisions captured from this audit
|
||||||
|
- 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 belongs in the homelab repo, and the live NOMAD runtime belongs at `/opt/doris-dashboard`.
|
||||||
|
|
||||||
|
### Doris Dashboard (local NOMAD homepage)
|
||||||
|
- 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`
|
||||||
|
- Service file source: `home/doris-dashboard/doris-dashboard.service`
|
||||||
|
- 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 `/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
|
||||||
|
- `whisper`
|
||||||
|
- `node-exporter`
|
||||||
|
|
||||||
|
Removing them should not be treated as a Project N.O.M.A.D. change or a Wings/Pelican change. They are standalone host-level containers from this audit’s point of view.
|
||||||
|
|
||||||
|
#### Restore checklist if removal causes trouble
|
||||||
|
1. Confirm what broke:
|
||||||
|
- speech/transcription endpoint dependency → restore `whisper`
|
||||||
|
- Prometheus scraping / host metrics gap → restore `node-exporter`
|
||||||
|
2. Recreate the container using the documented `docker run` command in this file.
|
||||||
|
3. Verify it came back cleanly:
|
||||||
|
- `docker ps --filter name=whisper`
|
||||||
|
- `docker ps --filter name=node-exporter`
|
||||||
|
- `docker logs --tail 50 whisper`
|
||||||
|
- `docker logs --tail 50 node-exporter`
|
||||||
|
4. Re-check any dependent dashboards, scrapers, or automations.
|
||||||
|
5. If the recreated container needs to persist long-term again, document *why* before leaving it in place.
|
||||||
|
|
||||||
|
#### Restore intent
|
||||||
|
- Restore `whisper` only if something still expects a local Faster Whisper HTTP endpoint on port `8786`.
|
||||||
|
- Restore `node-exporter` only if something still depends on host Prometheus metrics from this box.
|
||||||
|
- Do **not** fold either one into Project N.O.M.A.D. or Wings/Pelican casually; if they come back, they come back as deliberate standalone services unless John says otherwise.
|
||||||
|
|
||||||
## Pelican Panel (Game Server Management)
|
## Pelican Panel (Game Server Management)
|
||||||
- **URL:** `https://panel.paccoco.com`
|
- **URL:** `https://panel.paccoco.com`
|
||||||
@@ -49,7 +252,16 @@ Offline survival/knowledge server and game server host.
|
|||||||
- **Docker network:** `pelican_nw` (172.20.0.0/16)
|
- **Docker network:** `pelican_nw` (172.20.0.0/16)
|
||||||
|
|
||||||
## Active Game Servers
|
## Active Game Servers
|
||||||
- **Minecraft:** Purpur, Java 21, port 25565 TCP/UDP (direct host port forward)
|
- **Container `11d799cb-487a-416a-b6f9-9bf28f0e57e7`**
|
||||||
|
- Workload: Minecraft / Purpur-style Java server
|
||||||
|
- Java runtime image: `ghcr.io/pelican-eggs/yolks:java_25`
|
||||||
|
- Public port: `25565` TCP/UDP
|
||||||
|
- Data path: `/var/lib/pelican/volumes/11d799cb-487a-416a-b6f9-9bf28f0e57e7`
|
||||||
|
- **Container `d2aca31e-0d4d-433a-8295-cfad397dab0c`**
|
||||||
|
- Workload: modded Minecraft-style server (NeoForge-related env present)
|
||||||
|
- Java runtime image: `ghcr.io/pelican-eggs/yolks:java_21`
|
||||||
|
- Public port: `25566` TCP/UDP
|
||||||
|
- Data path: `/var/lib/pelican/volumes/d2aca31e-0d4d-433a-8295-cfad397dab0c`
|
||||||
|
|
||||||
## Networking Notes
|
## Networking Notes
|
||||||
- `panel.paccoco.com` → `127.0.0.1` in `/etc/hosts` for NAT loopback
|
- `panel.paccoco.com` → `127.0.0.1` in `/etc/hosts` for NAT loopback
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ Primary Docker host running all production workloads.
|
|||||||
RTX 2080 Ti used by:
|
RTX 2080 Ti used by:
|
||||||
- Plex (hardware transcoding)
|
- Plex (hardware transcoding)
|
||||||
- immich-ml (CUDA face recognition)
|
- immich-ml (CUDA face recognition)
|
||||||
- Whisper (CUDA speech-to-text, port 8786)
|
|
||||||
- Ollama light-tier inference (qwen2.5:14b and smaller)
|
- Ollama light-tier inference (qwen2.5:14b and smaller)
|
||||||
|
|
||||||
|
> **Note:** Whisper moved to N.O.M.A.D. (10.5.1.16:8786) as CPU inference — avoids VRAM conflict with Ollama. See [NOMAD.md](NOMAD.md).
|
||||||
|
|
||||||
Recommended models for 11GB VRAM:
|
Recommended models for 11GB VRAM:
|
||||||
| Model | VRAM | Best for |
|
| Model | VRAM | Best for |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
|
|||||||
## PlausibleDeniability
|
## PlausibleDeniability
|
||||||
|
|
||||||
### n8n workflow 16 (NFS Watchdog)
|
### n8n workflow 16 (NFS Watchdog)
|
||||||
- Runs `sudo docker restart` and `sudo bash .../mount-unraid-nfs.sh` over SSH from n8n container
|
- Runs `sudo docker restart` and `sudo /usr/bin/bash .../mount-unraid-nfs.sh` over SSH from n8n container
|
||||||
- Requires a NOPASSWD sudoers rule or SSH commands will hang waiting for a password:
|
- Requires a NOPASSWD sudoers rule or SSH commands will hang waiting for a password:
|
||||||
```
|
```
|
||||||
truenas_admin ALL=(ALL) NOPASSWD: /usr/bin/docker, /bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh
|
truenas_admin ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh
|
||||||
```
|
```
|
||||||
Add via `sudo visudo -f /etc/sudoers.d/n8n-watchdog`
|
Add via `sudo visudo -f /etc/sudoers.d/n8n-watchdog`
|
||||||
|
- **Must use `/usr/bin/bash` not `/bin/bash`** — on TrueNAS SCALE, bash is at `/usr/bin/bash`. The sudoers rule and the workflow command must match exactly or sudo will prompt for a password.
|
||||||
- SSH credential in n8n must use key-based auth (see n8n-workflows/README.md for keygen steps)
|
- SSH credential in n8n must use key-based auth (see n8n-workflows/README.md for keygen steps)
|
||||||
|
|
||||||
### immich-ml
|
### immich-ml
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ PAPERLESS_DBUSER=paperless
|
|||||||
PAPERLESS_DBPASS=changeme
|
PAPERLESS_DBPASS=changeme
|
||||||
PAPERLESS_SECRET_KEY=generate_with_openssl_rand_hex_32
|
PAPERLESS_SECRET_KEY=generate_with_openssl_rand_hex_32
|
||||||
PAPERLESS_REDIS=redis://:URL_ENCODED_REDIS_PASSWORD@shared-redis:6379
|
PAPERLESS_REDIS=redis://:URL_ENCODED_REDIS_PASSWORD@shared-redis:6379
|
||||||
|
PAPERLESS_TIKA_ENABLED=1
|
||||||
|
PAPERLESS_TIKA_ENDPOINT=http://tika:9998
|
||||||
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Karakeep
|
# Karakeep
|
||||||
|
|||||||
39
documents/README.md
Normal file
39
documents/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Documents Stack
|
||||||
|
|
||||||
|
Docker Compose stack for document and knowledge-capture services on PD.
|
||||||
|
|
||||||
|
## Paperless-NGX
|
||||||
|
|
||||||
|
- URL: `https://paperless.paccoco.com` / `http://pd:8083`
|
||||||
|
- Compose service: `paperless`
|
||||||
|
- Appdata: `/mnt/docker-ssd/docker/appdata/paperless/`
|
||||||
|
- Consume folder: `/mnt/unraid/data/paperless/consume` → `/usr/src/paperless/consume`
|
||||||
|
- Database: shared Postgres on `ix-databases_shared-databases`
|
||||||
|
- Redis: shared Redis via `PAPERLESS_REDIS`
|
||||||
|
|
||||||
|
Paperless has internal-only Tika and Gotenberg sidecars for Office/OpenDocument ingestion:
|
||||||
|
|
||||||
|
- `tika` (`apache/tika`) at `http://tika:9998`, memory limit `512m`
|
||||||
|
- `gotenberg` (`gotenberg/gotenberg`) at `http://gotenberg:3000`, memory limit `256m`
|
||||||
|
- Both sidecars live only on `documents_paperless_internal` and expose no host ports.
|
||||||
|
|
||||||
|
Required Paperless Tika env keys:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PAPERLESS_TIKA_ENABLED=1
|
||||||
|
PAPERLESS_TIKA_ENDPOINT=http://tika:9998
|
||||||
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported additional ingestion types include `.docx`, `.doc`, `.pptx`, `.xlsx`, and OpenDocument formats (`.odt`, `.ods`, `.odp`) in addition to the existing PDF/image OCR path.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
From `/mnt/docker-ssd/docker/compose/documents` on PD:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env config
|
||||||
|
docker compose --env-file .env down && docker compose --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not restart Docker on PD; use stack-level compose commands only.
|
||||||
@@ -2,7 +2,7 @@ name: documents
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
paperless:
|
paperless:
|
||||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
image: ghcr.io/paperless-ngx/paperless-ngx:2.16
|
||||||
container_name: paperless
|
container_name: paperless
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -24,6 +24,12 @@ services:
|
|||||||
PAPERLESS_TIME_ZONE: America/Chicago
|
PAPERLESS_TIME_ZONE: America/Chicago
|
||||||
PAPERLESS_OCR_LANGUAGE: eng
|
PAPERLESS_OCR_LANGUAGE: eng
|
||||||
PAPERLESS_URL: https://paperless.paccoco.com
|
PAPERLESS_URL: https://paperless.paccoco.com
|
||||||
|
PAPERLESS_TIKA_ENABLED: ${PAPERLESS_TIKA_ENABLED}
|
||||||
|
PAPERLESS_TIKA_ENDPOINT: ${PAPERLESS_TIKA_ENDPOINT}
|
||||||
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: ${PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
|
||||||
|
depends_on:
|
||||||
|
- tika
|
||||||
|
- gotenberg
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8000/ || exit 1"]
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8000/ || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -31,9 +37,26 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
|
- paperless-internal
|
||||||
- ix-databases_shared-databases
|
- ix-databases_shared-databases
|
||||||
- pangolin
|
- pangolin
|
||||||
|
|
||||||
|
tika:
|
||||||
|
image: apache/tika:3.1.0.0
|
||||||
|
container_name: paperless-tika
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 512m
|
||||||
|
networks:
|
||||||
|
- paperless-internal
|
||||||
|
|
||||||
|
gotenberg:
|
||||||
|
image: gotenberg/gotenberg:8.17
|
||||||
|
container_name: paperless-gotenberg
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 256m
|
||||||
|
networks:
|
||||||
|
- paperless-internal
|
||||||
|
|
||||||
karakeep:
|
karakeep:
|
||||||
image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION:-release}
|
image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION:-release}
|
||||||
container_name: karakeep
|
container_name: karakeep
|
||||||
@@ -95,6 +118,8 @@ services:
|
|||||||
- karakeep
|
- karakeep
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
paperless-internal:
|
||||||
|
name: documents_paperless_internal
|
||||||
ix-databases_shared-databases:
|
ix-databases_shared-databases:
|
||||||
external: true
|
external: true
|
||||||
pangolin:
|
pangolin:
|
||||||
|
|||||||
13
home/doris-dashboard/.gitignore
vendored
Normal file
13
home/doris-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
logs/
|
||||||
|
public/index.html
|
||||||
|
public/style.css.bak.*
|
||||||
|
bin/generate_dashboard.py.bak.*
|
||||||
|
data/articles.json
|
||||||
|
data/dashboard.json
|
||||||
|
data/homelab.json
|
||||||
|
data/sky.json
|
||||||
|
data/todos.json
|
||||||
|
data/weather.json
|
||||||
|
data/paperless_review.json
|
||||||
|
data/paperless_review_state.json
|
||||||
255
home/doris-dashboard/README.md
Normal file
255
home/doris-dashboard/README.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# Doris Dashboard
|
||||||
|
|
||||||
|
Local static homepage for John’s morning briefing and peek-anytime news dashboard.
|
||||||
|
|
||||||
|
Live runtime path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/doris-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Source path in the homelab repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated page:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/doris-dashboard/public/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it shows
|
||||||
|
|
||||||
|
- Morning briefing with fleshed-out cards
|
||||||
|
- Today panel for calendar placeholders
|
||||||
|
- To-do panel for Donetick/to-do placeholders
|
||||||
|
- Notes panel
|
||||||
|
- Sky / Space
|
||||||
|
- Homelab / Smart Home / Ham Radio
|
||||||
|
- Local news
|
||||||
|
- Other signal
|
||||||
|
- Source/candidate stats
|
||||||
|
|
||||||
|
## Generate manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/doris-dashboard
|
||||||
|
bin/generate_dashboard.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preview locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/doris-dashboard/public
|
||||||
|
python3 -m http.server 8787
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8787/
|
||||||
|
```
|
||||||
|
|
||||||
|
If viewing from another machine on the LAN, use NOMAD’s LAN IP instead of localhost and make sure the firewall allows the port.
|
||||||
|
|
||||||
|
## Cron refresh
|
||||||
|
|
||||||
|
Installed in `fizzlepoof`’s crontab:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# BEGIN DORIS DASHBOARD
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data inputs
|
||||||
|
|
||||||
|
Main news source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/fizzlepoof/.openclaw/workspace/doris-digest/data/candidates.json
|
||||||
|
/home/fizzlepoof/.openclaw/workspace/doris-digest/data/preferences.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Local dashboard placeholders:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
data/todos.json
|
||||||
|
data/calendar.json
|
||||||
|
data/notes.json
|
||||||
|
```
|
||||||
|
|
||||||
|
These are intentionally simple JSON arrays so calendar/Donetick integrations can be added later without rewriting the page.
|
||||||
|
|
||||||
|
## Next integrations to add
|
||||||
|
|
||||||
|
1. Donetick tasks
|
||||||
|
2. Calendar events
|
||||||
|
3. Weather / severe weather
|
||||||
|
4. Moon phase / ISS passes / meteor shower calendar
|
||||||
|
5. Homelab health pulse
|
||||||
|
6. Article feedback buttons or quick links back to Doris
|
||||||
|
|
||||||
|
## Privacy/deployment note
|
||||||
|
|
||||||
|
This dashboard is local-only on NOMAD unless John explicitly asks to expose it. Do not publish it externally without asking.
|
||||||
|
|
||||||
|
## LAN service
|
||||||
|
|
||||||
|
The dashboard is exposed on NOMAD's LAN IP via systemd:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://10.5.1.16:8787/
|
||||||
|
```
|
||||||
|
|
||||||
|
Service file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/etc/systemd/system/doris-dashboard.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Manage it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status doris-dashboard.service
|
||||||
|
sudo systemctl restart doris-dashboard.service
|
||||||
|
sudo systemctl stop doris-dashboard.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The service runs as `fizzlepoof`, serves `public/`, binds `0.0.0.0:8787`, and regenerates the page before starting.
|
||||||
|
|
||||||
|
At setup time UFW was inactive, so no firewall rule was required.
|
||||||
|
|
||||||
|
## Buildout added 2026-05-12
|
||||||
|
|
||||||
|
The dashboard generator now builds a richer local homepage directly on NOMAD.
|
||||||
|
|
||||||
|
Added sections/data:
|
||||||
|
|
||||||
|
- Weather metrics for Clarksville via Open-Meteo (`data/weather.json`)
|
||||||
|
- Moon phase / sky note calculated locally (`data/sky.json`)
|
||||||
|
- Homelab pulse checks (`data/homelab.json`): dashboard service, cron, digest freshness, workspace disk
|
||||||
|
- Article export from Doris Digest candidates (`data/articles.json`)
|
||||||
|
- Dashboard metadata (`data/dashboard.json`)
|
||||||
|
|
||||||
|
The generated page still lives at:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/doris-dashboard/public/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
And the LAN service remains:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://10.5.1.16:8787/
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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'
|
||||||
|
systemctl is-active doris-dashboard.service
|
||||||
|
crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p'
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Weather.gov and NASA API are not used; Open-Meteo worked reliably without an API key.
|
||||||
|
- Donetick live task import is still not enabled in the dashboard; `data/todos.json` remains the safe placeholder until the exact read-only endpoint is confirmed.
|
||||||
|
- Raw secrets are not written to dashboard output.
|
||||||
|
|
||||||
|
## Compact news layout
|
||||||
|
|
||||||
|
2026-05-13: News/article sections were changed from large cards to compact rows so the dashboard can keep plenty of articles without making John scroll forever.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- `generate_dashboard.py` renders article sections with `compact-card` rows by default.
|
||||||
|
- `public/style.css` contains the compact layout rules.
|
||||||
|
- Metrics/weather/homelab pulse remain large cards.
|
||||||
|
|
||||||
|
## Donetick integration
|
||||||
|
|
||||||
|
2026-05-13: The dashboard now imports open Donetick chores read-only during generation.
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Secrets remain encrypted in `/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc`.
|
||||||
|
- `bin/generate_dashboard.py` decrypts secrets at runtime using the local encryption key and logs in with the Doris service account.
|
||||||
|
- It reads `/api/v1/chores` and `/api/v1/users` only.
|
||||||
|
- It writes sanitized task data to `data/todos.json` for the dashboard.
|
||||||
|
- It does not expose tokens, passwords, or raw secrets in generated HTML/JSON.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/doris-dashboard
|
||||||
|
bin/generate_dashboard.py
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
for item in json.loads(Path('data/todos.json').read_text()):
|
||||||
|
print(item.get('time'), item.get('title'), item.get('assignee'))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Donetick compact board + topic feedback
|
||||||
|
|
||||||
|
2026-05-14: The dashboard's Donetick section was reworked to match the tighter PD Dakboard pattern more closely.
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- Donetick renders compact task cards with due cue, task title, label chips, and a short subtitle.
|
||||||
|
- Tasks are sorted by urgency first (overdue/today/upcoming/no due date), then due time.
|
||||||
|
- The dashboard injects a pinned Paperless follow-up card into the Donetick area so that cleanup work stays visible.
|
||||||
|
- News cards now show `👍 / 👎` topic buttons.
|
||||||
|
- Topic feedback is persisted through the local dashboard server into `../doris-digest/data/feedback.json` with `origin: "dashboard"`.
|
||||||
|
- `Reset hidden topics` clears both browser state and dashboard-origin feedback entries.
|
||||||
|
- Paperless review cards now have `Acknowledge` / `Dismiss` actions persisted to `data/paperless_review_state.json`.
|
||||||
|
- Major dashboard sections are collapsible and remember collapsed state in the browser.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Paperless review queue contract
|
||||||
|
|
||||||
|
The dashboard displays sanitized Paperless intake triage items written by n8n at `data/paperless_review.json`. Example contract: `data/paperless_review.example.json`.
|
||||||
|
|
||||||
|
Expected shape is a JSON array. Each item may include:
|
||||||
|
|
||||||
|
- `id`, `title`, `original_filename`, `added`, `archive_url`
|
||||||
|
- `current.correspondent`, `current.document_type`, `current.tags`
|
||||||
|
- `triage.summary`, `triage.suggested_title`, `triage.document_type`, `triage.suggested_tags`, `triage.due_date`, `triage.needs_review`, `triage.urgency`, `triage.reason`, `triage.confidence`
|
||||||
|
- `safety.forced_review`, `safety.eligible_for_safe_apply`, `safety.apply_safe_enabled`
|
||||||
|
- `queued_at`
|
||||||
|
|
||||||
|
Do not write tokens, raw OCR text, API responses, or credentials into this file. It is display-only derived data for local review.
|
||||||
10
home/doris-dashboard/bin/cron_generate_dashboard.sh
Executable file
10
home/doris-dashboard/bin/cron_generate_dashboard.sh
Executable file
@@ -0,0 +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
|
||||||
106
home/doris-dashboard/bin/dashboard_server.py
Executable file
106
home/doris-dashboard/bin/dashboard_server.py
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from functools import partial
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PUBLIC = ROOT / 'public'
|
||||||
|
DIGEST_ROOT = Path(os.environ.get('DORIS_DIGEST_DIR', '/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||||||
|
DIGEST_FEEDBACK = DIGEST_ROOT / 'data' / 'feedback.json'
|
||||||
|
PAPERLESS_STATE = ROOT / 'data' / 'paperless_review_state.json'
|
||||||
|
HOST = os.environ.get('HOST', '0.0.0.0')
|
||||||
|
PORT = int(os.environ.get('PORT', '8787'))
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path, default):
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text()) if path.exists() else default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, value) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(value, indent=2) + '\n')
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, directory=None, **kwargs):
|
||||||
|
super().__init__(*args, directory=str(PUBLIC), **kwargs)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get('Content-Length', '0') or '0')
|
||||||
|
raw = self.rfile.read(length) if length else b'{}'
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode('utf-8') or '{}')
|
||||||
|
except Exception:
|
||||||
|
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'invalid_json'})
|
||||||
|
|
||||||
|
if self.path == '/api/topic-feedback':
|
||||||
|
return self._topic_feedback(payload)
|
||||||
|
if self.path == '/api/topic-feedback/reset':
|
||||||
|
return self._topic_reset()
|
||||||
|
if self.path == '/api/paperless-action':
|
||||||
|
return self._paperless_action(payload)
|
||||||
|
return self._json(HTTPStatus.NOT_FOUND, {'ok': False, 'error': 'not_found'})
|
||||||
|
|
||||||
|
def _topic_feedback(self, payload: dict):
|
||||||
|
term = str(payload.get('term', '')).strip()
|
||||||
|
vote = str(payload.get('vote', '')).strip().lower()
|
||||||
|
if not term or vote not in {'up', 'down'}:
|
||||||
|
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'})
|
||||||
|
items = load_json(DIGEST_FEEDBACK, [])
|
||||||
|
items = [x for x in items if not (str(x.get('origin', '')) == 'dashboard' and str(x.get('term', '')).strip().lower() == term.lower())]
|
||||||
|
items.append({
|
||||||
|
'term': term,
|
||||||
|
'vote': vote,
|
||||||
|
'note': f"Dashboard {'hide' if vote == 'down' else 'prefer'} topic: {term}",
|
||||||
|
'origin': 'dashboard',
|
||||||
|
'source': str(payload.get('source', '')).strip(),
|
||||||
|
'domain': str(payload.get('domain', '')).strip(),
|
||||||
|
})
|
||||||
|
save_json(DIGEST_FEEDBACK, items)
|
||||||
|
return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)})
|
||||||
|
|
||||||
|
def _topic_reset(self):
|
||||||
|
items = load_json(DIGEST_FEEDBACK, [])
|
||||||
|
items = [x for x in items if str(x.get('origin', '')) != 'dashboard']
|
||||||
|
save_json(DIGEST_FEEDBACK, items)
|
||||||
|
return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)})
|
||||||
|
|
||||||
|
def _paperless_action(self, payload: dict):
|
||||||
|
item_id = str(payload.get('id', '')).strip()
|
||||||
|
action = str(payload.get('action', '')).strip().lower()
|
||||||
|
if not item_id or action not in {'needs_attention', 'good_to_go'}:
|
||||||
|
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'})
|
||||||
|
state = load_json(PAPERLESS_STATE, {})
|
||||||
|
current = state.get(item_id, {}) if isinstance(state, dict) else {}
|
||||||
|
current_status = str(current.get('status') or '').lower()
|
||||||
|
if action == 'needs_attention':
|
||||||
|
new_status = 'active' if current_status == 'needs_attention' else 'needs_attention'
|
||||||
|
else:
|
||||||
|
new_status = 'good_to_go'
|
||||||
|
state[item_id] = {'status': new_status}
|
||||||
|
save_json(PAPERLESS_STATE, state)
|
||||||
|
return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status})
|
||||||
|
|
||||||
|
def _json(self, status: HTTPStatus, payload: dict):
|
||||||
|
body = json.dumps(payload).encode('utf-8')
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
print('%s - - [%s] %s' % (self.address_string(), self.log_date_time_string(), fmt % args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
handler = partial(Handler, directory=str(PUBLIC))
|
||||||
|
ThreadingHTTPServer((HOST, PORT), handler).serve_forever()
|
||||||
599
home/doris-dashboard/bin/generate_dashboard.py
Executable file
599
home/doris-dashboard/bin/generate_dashboard.py
Executable file
@@ -0,0 +1,599 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html, json, math, os, re, shutil, subprocess, urllib.error, urllib.request
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||||||
|
PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs'
|
||||||
|
PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json'
|
||||||
|
LAT=36.53; LON=-87.36
|
||||||
|
LOCAL_TZ=ZoneInfo('America/Chicago')
|
||||||
|
|
||||||
|
LABELS={
|
||||||
|
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
|
||||||
|
'local':('📍','Local'),'radio':('📻','Ham Radio'),'security':('⚠️','Security'),
|
||||||
|
'tech':('🧰','Tech'),'us':('📰','News'),'world':('🌍','World'),'cooking':('🍳','Cooking'),'gaming':('🎮','Gaming')}
|
||||||
|
WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Rain showers',81:'Rain showers',82:'Heavy showers',95:'Thunderstorm'}
|
||||||
|
LOW_VALUE_LABELS={'shared','duesoon','submitted','waitingsubmission','notes'}
|
||||||
|
PRIORITY_COLORS={1:'#ff6b6b',2:'#ff9f1c',3:'#ffd166',4:'#8ecae6'}
|
||||||
|
|
||||||
|
def esc(v:Any)->str: return html.escape(str(v or ''),quote=True)
|
||||||
|
def load_json(p:Path,d:Any)->Any:
|
||||||
|
try: return json.loads(p.read_text()) if p.exists() else d
|
||||||
|
except Exception: return d
|
||||||
|
|
||||||
|
def strip_markup(text:str)->str:
|
||||||
|
text=re.sub(r'<[^>]+>',' ',text or '')
|
||||||
|
return re.sub(r'\s+',' ',html.unescape(text)).strip()
|
||||||
|
|
||||||
|
def parse_time(v:str)->datetime:
|
||||||
|
try: return datetime.fromisoformat((v or '').replace('Z','+00:00')).astimezone(timezone.utc)
|
||||||
|
except Exception: return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def parse_dt(v:str|None)->datetime|None:
|
||||||
|
if not v: return None
|
||||||
|
try: return datetime.fromisoformat(v.replace('Z','+00:00'))
|
||||||
|
except Exception: return None
|
||||||
|
|
||||||
|
def c_to_f(c:float|int|None)->str:
|
||||||
|
if c is None: return '—'
|
||||||
|
return f"{round(c*9/5+32)}°F"
|
||||||
|
|
||||||
|
def kmh_to_mph(k:float|int|None)->str:
|
||||||
|
if k is None: return '—'
|
||||||
|
return f"{round(k*0.621371)} mph"
|
||||||
|
|
||||||
|
def moon_phase(dt:datetime)->dict[str,str]:
|
||||||
|
# Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days.
|
||||||
|
known=datetime(2000,1,6,18,14,tzinfo=timezone.utc)
|
||||||
|
days=(dt.astimezone(timezone.utc)-known).total_seconds()/86400
|
||||||
|
age=days%29.53058867
|
||||||
|
illum=(1-math.cos(2*math.pi*age/29.53058867))/2
|
||||||
|
names=[(1.85,'New Moon','🌑'),(5.54,'Waxing Crescent','🌒'),(9.23,'First Quarter','🌓'),(12.92,'Waxing Gibbous','🌔'),(16.61,'Full Moon','🌕'),(20.30,'Waning Gibbous','🌖'),(23.99,'Last Quarter','🌗'),(27.68,'Waning Crescent','🌘'),(29.54,'New Moon','🌑')]
|
||||||
|
name,emoji='New Moon','🌑'
|
||||||
|
for limit,n,e in names:
|
||||||
|
if age<limit: name,emoji=n,e; break
|
||||||
|
return {'emoji':emoji,'name':name,'age':f'{age:.1f} days','illumination':f'{illum*100:.0f}%'}
|
||||||
|
|
||||||
|
def fetch_weather()->dict[str,Any]:
|
||||||
|
url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}'
|
||||||
|
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature'
|
||||||
|
'&hourly=temperature_2m,precipitation_probability'
|
||||||
|
'&daily=precipitation_sum,sunrise,sunset&timezone=America/Chicago&forecast_days=3')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url,timeout=12) as r: raw=json.load(r)
|
||||||
|
cur=raw.get('current',{}); daily=raw.get('daily',{}); hourly=raw.get('hourly',{})
|
||||||
|
probs=hourly.get('precipitation_probability') or []
|
||||||
|
return {'ok':True,'current':cur,'daily':daily,'max_precip':max(probs[:24] or [0]),'condition':WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))),'source':'Open-Meteo'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok':False,'error':str(e),'source':'Open-Meteo'}
|
||||||
|
|
||||||
|
def decrypt_donetick()->dict[str,Any]|None:
|
||||||
|
secret=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc')
|
||||||
|
keyfile=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key')
|
||||||
|
if not secret.exists() or not keyfile.exists(): return None
|
||||||
|
try:
|
||||||
|
res=subprocess.run(['openssl','enc','-aes-256-cbc','-d','-pbkdf2','-in',str(secret),'-k',keyfile.read_text().strip()],capture_output=True,text=True,timeout=8,check=True)
|
||||||
|
return json.loads(res.stdout).get('donetick')
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_donetick_todos()->list[dict[str,Any]]:
|
||||||
|
sec=decrypt_donetick()
|
||||||
|
if not sec: return []
|
||||||
|
try:
|
||||||
|
login=urllib.request.Request(sec['instance']+sec['jwt_endpoint'],data=json.dumps({'username':sec['service_account']['username'],'password':sec['service_account']['password']}).encode(),headers={'Content-Type':'application/json','Accept':'application/json'},method='POST')
|
||||||
|
with urllib.request.urlopen(login,timeout=12) as r: token=json.loads(r.read())['access_token']
|
||||||
|
headers={'Authorization':'Bearer '+token,'Accept':'application/json'}
|
||||||
|
req=urllib.request.Request(sec['instance']+'/api/v1/chores',headers=headers)
|
||||||
|
with urllib.request.urlopen(req,timeout=12) as r: chores=json.loads(r.read()).get('res',[])
|
||||||
|
users={}
|
||||||
|
try:
|
||||||
|
ureq=urllib.request.Request(sec['instance']+'/api/v1/users',headers=headers)
|
||||||
|
with urllib.request.urlopen(ureq,timeout=12) as r:
|
||||||
|
users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])}
|
||||||
|
except Exception: pass
|
||||||
|
out=[]
|
||||||
|
for c in chores:
|
||||||
|
if c.get('status') not in (0,None): continue
|
||||||
|
due=c.get('nextDueDate') or ''
|
||||||
|
assignee=users.get(c.get('assignedTo')) or ('Unassigned' if not c.get('assignedTo') else f"User {c.get('assignedTo')}")
|
||||||
|
due_title,state,due_local=due_title_and_state(due)
|
||||||
|
out.append({
|
||||||
|
'title':c.get('name','Untitled task'),
|
||||||
|
'time':due[:16].replace('T',' '),
|
||||||
|
'body':strip_markup(c.get('description','')),
|
||||||
|
'assignee':assignee,
|
||||||
|
'priority':priority_num(c.get('priority')) or 0,
|
||||||
|
'priority_text':priority_text(c.get('priority')) or '',
|
||||||
|
'due':due,
|
||||||
|
'due_title':due_title,
|
||||||
|
'state':state,
|
||||||
|
'subtitle':subtitle_text(c),
|
||||||
|
'chips':visible_label_chips(c),
|
||||||
|
'source':'Donetick',
|
||||||
|
'id':c.get('id'),
|
||||||
|
'_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1),
|
||||||
|
'_sort_missing':0 if due_local else 1,
|
||||||
|
'_sort_due':due_local.timestamp() if due_local else 99999999999,
|
||||||
|
'_sort_name':str(c.get('name','')).lower(),
|
||||||
|
})
|
||||||
|
out.sort(key=lambda x:(x.get('_sort_overdue',9),x.get('_sort_missing',9),x.get('_sort_due',99999999999),x.get('_sort_name','')))
|
||||||
|
for item in out:
|
||||||
|
for k in ('_sort_overdue','_sort_missing','_sort_due','_sort_name'):
|
||||||
|
item.pop(k,None)
|
||||||
|
return out[:10]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def priority_text(priority:Any)->str|None:
|
||||||
|
try:
|
||||||
|
p=int(priority)
|
||||||
|
return f'P{p}' if p in (1,2,3,4) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def priority_num(priority:Any)->int|None:
|
||||||
|
try:
|
||||||
|
p=int(priority)
|
||||||
|
return p if p in (1,2,3,4) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def recurrence_text(task:dict[str,Any])->str|None:
|
||||||
|
ftype=str(task.get('frequencyType','') or '').strip().lower()
|
||||||
|
freq=task.get('frequency')
|
||||||
|
try: freq_num=int(freq) if freq is not None else None
|
||||||
|
except Exception: freq_num=None
|
||||||
|
if not ftype: return None
|
||||||
|
if ftype=='daily': return 'Daily' if freq_num in (None,1) else f'Every {freq_num} days'
|
||||||
|
if ftype=='weekly': return 'Weekly' if freq_num in (None,1) else f'Every {freq_num} weeks'
|
||||||
|
if ftype=='monthly': return 'Monthly' if freq_num in (None,1) else f'Every {freq_num} months'
|
||||||
|
if ftype=='yearly': return 'Yearly' if freq_num in (None,1) else f'Every {freq_num} years'
|
||||||
|
if ftype=='once': return 'One-time'
|
||||||
|
return ftype.capitalize()
|
||||||
|
|
||||||
|
def due_title_and_state(due_raw:str|None)->tuple[str,str,datetime|None]:
|
||||||
|
dt=parse_dt(due_raw)
|
||||||
|
if not dt: return ('No due date','nodue',None)
|
||||||
|
local=dt.astimezone(LOCAL_TZ)
|
||||||
|
now=datetime.now(LOCAL_TZ)
|
||||||
|
days=(local.date()-now.date()).days
|
||||||
|
time_text=local.strftime('%-I:%M %p')
|
||||||
|
if days < 0:
|
||||||
|
if abs(days)==1: return (f'Overdue • Yesterday {time_text}','overdue',local)
|
||||||
|
return (f"Overdue • {local.strftime('%a, %b %-d')} {time_text}",'overdue',local)
|
||||||
|
if days == 0: return (f'Due today • {time_text}','today',local)
|
||||||
|
if days == 1: return (f'Due tomorrow • {time_text}','tomorrow',local)
|
||||||
|
if days <= 6: return (f"Due {local.strftime('%a')} • {time_text}",'upcoming',local)
|
||||||
|
return (f"Due {local.strftime('%b %-d')} • {time_text}",'upcoming',local)
|
||||||
|
|
||||||
|
def dedupe_parts(parts:list[str])->list[str]:
|
||||||
|
seen=set(); out=[]
|
||||||
|
for p in parts:
|
||||||
|
if not p: continue
|
||||||
|
key=p.strip().lower()
|
||||||
|
if key in seen: continue
|
||||||
|
seen.add(key); out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def subtitle_text(task:dict[str,Any])->str:
|
||||||
|
parts=[]
|
||||||
|
labels=task.get('labelsV2') or []
|
||||||
|
label_names=[str(lbl.get('name','')).strip() for lbl in labels if lbl.get('name')]
|
||||||
|
prominent=[x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS]
|
||||||
|
low=[x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS]
|
||||||
|
parts.extend(prominent[:3])
|
||||||
|
ptxt=priority_text(task.get('priority'))
|
||||||
|
if ptxt: parts.append(ptxt)
|
||||||
|
rtxt=recurrence_text(task)
|
||||||
|
if rtxt:
|
||||||
|
label_keys={x.strip().lower() for x in label_names}
|
||||||
|
if rtxt.strip().lower() not in label_keys: parts.append(rtxt)
|
||||||
|
if not prominent and low: parts.extend(low[:1])
|
||||||
|
return ' • '.join(dedupe_parts(parts))
|
||||||
|
|
||||||
|
def visible_label_chips(task:dict[str,Any])->list[dict[str,str]]:
|
||||||
|
chips=[]
|
||||||
|
for lbl in task.get('labelsV2') or []:
|
||||||
|
name=str(lbl.get('name','')).strip()
|
||||||
|
if not name or name.lower() in LOW_VALUE_LABELS: continue
|
||||||
|
color=str(lbl.get('color','')).strip() or '#607d8b'
|
||||||
|
chips.append({'name':name,'color':color})
|
||||||
|
return chips[:4]
|
||||||
|
|
||||||
|
def run(cmd:list[str])->str:
|
||||||
|
try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip()
|
||||||
|
except Exception: return ''
|
||||||
|
|
||||||
|
def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]:
|
||||||
|
usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace')
|
||||||
|
disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)'
|
||||||
|
dash=run(['systemctl','is-active','doris-dashboard.service']) or 'unknown'
|
||||||
|
cron='present' if 'DORIS DASHBOARD' in run(['bash','-lc','crontab -l 2>/dev/null']) else 'missing'
|
||||||
|
newest=max((parse_time(i.get('collected_at') or i.get('published_at') or '') for i in candidates), default=datetime.now(timezone.utc))
|
||||||
|
age=(datetime.now(timezone.utc)-newest).total_seconds()/3600
|
||||||
|
return [
|
||||||
|
{'label':'Dashboard service','value':dash,'state':'ok' if dash=='active' else 'warn'},
|
||||||
|
{'label':'Dashboard cron','value':cron,'state':'ok' if cron=='present' else 'warn'},
|
||||||
|
{'label':'Digest freshness','value':f'{age:.1f}h since newest candidate','state':'ok' if age<8 else 'warn'},
|
||||||
|
{'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'},
|
||||||
|
]
|
||||||
|
|
||||||
|
def score_item(item:dict[str,Any],prefs:dict[str,Any])->float:
|
||||||
|
score=float(item.get('priority',1)); text=f"{item.get('title','')} {item.get('summary','')}".lower()
|
||||||
|
if item.get('category') in prefs.get('boost_categories',[]): score+=2
|
||||||
|
if item.get('category') in prefs.get('quiet_categories',[]): score-=3
|
||||||
|
for kw in prefs.get('boost_keywords',[]):
|
||||||
|
if kw.lower() in text: score+=1.5
|
||||||
|
for kw in prefs.get('mute_keywords',[]):
|
||||||
|
if kw.lower() in text: score-=3
|
||||||
|
if item.get('source')=='CISA Alerts':
|
||||||
|
terms=[t.lower() for t in prefs.get('homelab_relevance_keywords',[])]
|
||||||
|
if terms and not any(t in text for t in terms): score-=8
|
||||||
|
return score
|
||||||
|
|
||||||
|
def select_items(items:list[dict[str,Any]],prefs:dict[str,Any],max_items:int=14)->list[dict[str,Any]]:
|
||||||
|
now=datetime.now(timezone.utc); recent=[]
|
||||||
|
for i in items:
|
||||||
|
if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i)
|
||||||
|
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||||||
|
out=[]; seen=set(); sources=Counter()
|
||||||
|
for i in recent:
|
||||||
|
url=i.get('url',''); src=i.get('source','')
|
||||||
|
if url in seen or sources[src]>=2: continue
|
||||||
|
out.append(i); seen.add(url); sources[src]+=1
|
||||||
|
if len(out)>=max_items: break
|
||||||
|
return out
|
||||||
|
|
||||||
|
def label(cat:str)->str:
|
||||||
|
e,n=LABELS.get(cat,('🧩',(cat or 'Other').title())); return f'{e} {n}'
|
||||||
|
|
||||||
|
def slugify(text:str)->str:
|
||||||
|
return re.sub(r'[^a-z0-9]+','-',(text or '').strip().lower()).strip('-') or 'section'
|
||||||
|
|
||||||
|
def why(i:dict[str,Any])->str:
|
||||||
|
cat=i.get('category'); src=i.get('source','')
|
||||||
|
if cat=='astronomy': return 'Space/astronomy signal from your YouTube + feed tuning.'
|
||||||
|
if cat=='homelab': return 'Practical self-hosting / infrastructure signal.'
|
||||||
|
if cat=='smart_home': return 'Smart-home/Home Assistant adjacent.'
|
||||||
|
if cat=='local': return 'Local or regional relevance.'
|
||||||
|
if cat=='radio': return 'Ham-radio interest signal.'
|
||||||
|
if cat=='security': return 'Kept only because it appears practically relevant.'
|
||||||
|
return f'Interesting signal from {src}.'
|
||||||
|
|
||||||
|
def card(i:dict[str,Any], compact:bool=False)->str:
|
||||||
|
summary=(i.get('summary') or '').strip()[:170 if compact else 300]
|
||||||
|
when=(i.get('published_at','')[:16].replace('T',' '))
|
||||||
|
cls='compact-card' if compact else 'card'
|
||||||
|
why_html='' if compact else f"<div class='why'>Why: {esc(why(i))}</div>"
|
||||||
|
topic=(i.get('category') or 'other').strip() or 'other'
|
||||||
|
source=(i.get('source') or '').strip()
|
||||||
|
domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.')
|
||||||
|
feedback_html='' if not compact else (
|
||||||
|
f"<div class='feedback'>"
|
||||||
|
f"<button type='button' class='feedback-btn' data-feedback='up' aria-pressed='false' title='Prefer more {esc(topic)} stories'>👍</button>"
|
||||||
|
f"<button type='button' class='feedback-btn' data-feedback='down' aria-pressed='false' title='Less like this — hide this story'>👎</button>"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
|
||||||
|
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
|
||||||
|
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
|
||||||
|
<p>{esc(summary)}</p>{feedback_html}{why_html}</article>"""
|
||||||
|
|
||||||
|
def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=True)->str:
|
||||||
|
body=''.join(card(i,compact=compact) for i in items) if items else f"<p class='empty'>{esc(empty)}</p>"
|
||||||
|
style='news-list' if compact else 'grid'
|
||||||
|
return collapsible_section(title,sub,f"<div class='{style}'>{body}</div>",slugify(title),open_by_default=True)
|
||||||
|
|
||||||
|
def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default:bool=True,extra_class:str='')->str:
|
||||||
|
open_attr=' open' if open_by_default else ''
|
||||||
|
sub_html=f"<p>{esc(sub)}</p>" if sub else ''
|
||||||
|
return f"<details class='collapsible {esc(extra_class)}' data-section-key='{esc(key)}'{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
|
||||||
|
|
||||||
|
def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]:
|
||||||
|
out=list(items or [])
|
||||||
|
title='Paperless notes/tags audit'
|
||||||
|
if not any((x.get('title') or '').strip().lower()==title.lower() for x in out):
|
||||||
|
out.insert(0,{"title":title,"time":"","body":"Review and correct all Paperless notes and tags. Keep this visible until the wider cleanup is actually done.","assignee":"fizzlepoof","priority":2,"priority_text":"P2","due":"","due_title":"Pinned follow-up","state":"today","subtitle":"Dashboard • P2","chips":[],"source":"Dashboard"})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def load_paperless_review()->list[dict[str,Any]]:
|
||||||
|
p=DATA/'paperless_review.json'
|
||||||
|
remote=os.environ.get('PAPERLESS_TRIAGE_REMOTE_QUEUE','truenas_admin@10.5.1.6:/mnt/docker-ssd/docker/appdata/n8n/paperless-triage/paperless_review.json')
|
||||||
|
ssh_key=os.environ.get('PAPERLESS_TRIAGE_SSH_KEY','/home/fizzlepoof/.ssh/id_ed25519_andys')
|
||||||
|
if remote:
|
||||||
|
tmp=DATA/'paperless_review.remote.tmp'
|
||||||
|
try:
|
||||||
|
cmd=['scp','-q','-o','ConnectTimeout=8']
|
||||||
|
if ssh_key and Path(ssh_key).exists():
|
||||||
|
cmd.extend(['-i',ssh_key])
|
||||||
|
cmd.extend([remote,str(tmp)])
|
||||||
|
res=subprocess.run(cmd,capture_output=True,text=True,timeout=12)
|
||||||
|
if res.returncode==0 and tmp.exists(): tmp.replace(p)
|
||||||
|
elif tmp.exists(): tmp.unlink()
|
||||||
|
except Exception:
|
||||||
|
try: tmp.unlink()
|
||||||
|
except Exception: pass
|
||||||
|
try: return json.loads(p.read_text())
|
||||||
|
except Exception: return []
|
||||||
|
|
||||||
|
def load_paperless_state()->dict[str,Any]:
|
||||||
|
return load_json(PAPERLESS_STATE_PATH,{}) or {}
|
||||||
|
|
||||||
|
def paperless_review_section()->str:
|
||||||
|
items=load_paperless_review()
|
||||||
|
state=load_paperless_state()
|
||||||
|
if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','<p class="empty">No documents waiting.</p>','paperless-review',open_by_default=False,extra_class='panel')
|
||||||
|
urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'}
|
||||||
|
active=[]; needs_attention=[]
|
||||||
|
for item in items:
|
||||||
|
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
|
||||||
|
if status in {'dismissed','good_to_go'}:
|
||||||
|
continue
|
||||||
|
if status=='acknowledged':
|
||||||
|
status='needs_attention'
|
||||||
|
(needs_attention if status=='needs_attention' else active).append(item)
|
||||||
|
def render_card(item:dict[str,Any], needs_attention_card:bool=False)->str:
|
||||||
|
triage=item.get('triage',{}) or {}
|
||||||
|
current=item.get('current',{}) or {}
|
||||||
|
urgency=triage.get('urgency','none')
|
||||||
|
summary=strip_markup(triage.get('summary',''))
|
||||||
|
reason=strip_markup(triage.get('reason',''))
|
||||||
|
meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x]
|
||||||
|
meta=' • '.join(str(x) for x in meta_parts)
|
||||||
|
status_class=' needs-attention' if needs_attention_card else ''
|
||||||
|
primary_label='Clear flag' if needs_attention_card else 'Needs attention'
|
||||||
|
primary_action='needs_attention'
|
||||||
|
return f"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
|
||||||
|
<div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='{primary_action}'>{primary_label}</button><button type='button' class='paperless-btn good' data-paperless-action='good_to_go'>Good to go</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div></div>
|
||||||
|
<h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4>
|
||||||
|
{f"<p class='summary'>{esc(summary)}</p>" if summary else ''}
|
||||||
|
{f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''}
|
||||||
|
{f"<p class='paperless-reason'>{esc(reason)}</p>" if reason else ''}
|
||||||
|
</article>"""
|
||||||
|
visible_active=active[:3]
|
||||||
|
overflow_active=active[3:]
|
||||||
|
active_cards=''.join(render_card(item,False) for item in visible_active)
|
||||||
|
flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8])
|
||||||
|
count=len(active)
|
||||||
|
flag_count=len(needs_attention)
|
||||||
|
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
|
||||||
|
if overflow_active:
|
||||||
|
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
|
||||||
|
if flag_count:
|
||||||
|
body += f"<details class='paperless-acked' open><summary>Needs attention <span class='badge'>{esc(str(flag_count))}</span></summary><div class='paperless-grid acked-grid'>{flagged_cards}</div><p class='empty'>Flagged items stay here so I can revisit them later and, if needed, ask you about the issue.</p></details>"
|
||||||
|
if count+flag_count>3:
|
||||||
|
body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</p>"
|
||||||
|
return collapsible_section(f'📄 Paperless Review ({count} active)',f'{flag_count} flagged for follow-up · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=(count+flag_count)>0,extra_class='panel')
|
||||||
|
|
||||||
|
def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
|
||||||
|
if not items:
|
||||||
|
body=f"<li class='empty'>{esc(empty)}</li>"
|
||||||
|
else:
|
||||||
|
rows=[]
|
||||||
|
for x in items[:8]:
|
||||||
|
kicker=x.get('time') or x.get('title') or x.get('label')
|
||||||
|
headline=x.get('title') if x.get('time') else ''
|
||||||
|
assignee=x.get('assignee') or ''
|
||||||
|
detail=x.get('body') or x.get('summary') or x.get('value') or ''
|
||||||
|
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{f'<span class=\'muted\'>{esc(assignee)}</span>' if assignee else ''}</div>{f'<div class=\'panel-item-title\'>{esc(headline)}</div>' if headline else ''}{f'<div class=\'panel-item-body\'>{esc(detail)}</div>' if detail else ''}</li>")
|
||||||
|
body=''.join(rows)
|
||||||
|
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
|
||||||
|
|
||||||
|
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||||||
|
if not items:
|
||||||
|
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
|
||||||
|
rows=[]
|
||||||
|
for x in items[:6]:
|
||||||
|
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" for c in (x.get('chips') or []))
|
||||||
|
subtitle_text=(x.get('subtitle') or '').strip()
|
||||||
|
chip_names={str(c.get('name','')).strip().lower() for c in (x.get('chips') or []) if c.get('name')}
|
||||||
|
subtitle_parts=[]
|
||||||
|
for part in [p.strip() for p in subtitle_text.split('•') if p.strip()]:
|
||||||
|
if part.lower() in chip_names:
|
||||||
|
continue
|
||||||
|
subtitle_parts.append(part)
|
||||||
|
subtitle=' • '.join(subtitle_parts)
|
||||||
|
state=esc(x.get('state') or 'upcoming')
|
||||||
|
due_style=''
|
||||||
|
if state=='overdue': due_style=' style="color:#ff6b6b;"'
|
||||||
|
elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"'
|
||||||
|
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-state-dot' aria-hidden='true'></div><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{f'<span class=\'task-assignee\'>{esc(x.get("assignee") or "")}</span>' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{f'<div class=\'task-chips\'>{chips}</div>' if chips else ''}{f'<div class=\'task-subtitle\'>{esc(subtitle)}</div>' if subtitle else ''}</article>")
|
||||||
|
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||||||
|
|
||||||
|
def feedback_bootstrap()->str:
|
||||||
|
return """<script>
|
||||||
|
(()=>{
|
||||||
|
const KEY='doris-dashboard-topic-prefs-v2';
|
||||||
|
const HIDDEN_CARD_KEY='doris-dashboard-hidden-cards-v1';
|
||||||
|
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
|
||||||
|
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"hidden":[]}')}catch(e){return {hidden:[]}}};
|
||||||
|
const safeHiddenCards=()=>{try{return JSON.parse(localStorage.getItem(HIDDEN_CARD_KEY)||'[]')}catch(e){return []}};
|
||||||
|
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
|
||||||
|
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({hidden:[...new Set(prefs.hidden||[])]}));
|
||||||
|
const saveHiddenCards=(ids)=>localStorage.setItem(HIDDEN_CARD_KEY, JSON.stringify([...new Set(ids||[])]));
|
||||||
|
const prefs=safeRead();
|
||||||
|
let hiddenCards=safeHiddenCards();
|
||||||
|
const collapsed=safeCollapse();
|
||||||
|
const cards=[...document.querySelectorAll('[data-topic]')];
|
||||||
|
const sections=[...document.querySelectorAll('details[data-section-key]')];
|
||||||
|
const toast=document.createElement('div');
|
||||||
|
toast.className='dashboard-toast';
|
||||||
|
toast.hidden=true;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
let lastAction=null;
|
||||||
|
let toastTimer=null;
|
||||||
|
const postJson=(url,payload)=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
|
||||||
|
const cardId=(card)=>card.querySelector('h3 a')?.getAttribute('href')||card.querySelector('h3 a')?.textContent||card.dataset.topic||Math.random().toString(36).slice(2);
|
||||||
|
cards.forEach(card=>{card.dataset.cardId=cardId(card);});
|
||||||
|
const setToast=(html)=>{
|
||||||
|
toast.innerHTML=html;
|
||||||
|
toast.hidden=false;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer=setTimeout(()=>{toast.hidden=true;},4500);
|
||||||
|
};
|
||||||
|
const sync=()=>{
|
||||||
|
cards.forEach(card=>{
|
||||||
|
const topic=card.dataset.topic||'other';
|
||||||
|
const hidden=(prefs.hidden||[]).includes(topic) || hiddenCards.includes(card.dataset.cardId||'');
|
||||||
|
card.style.display=hidden?'none':'';
|
||||||
|
card.classList.remove('topic-liked');
|
||||||
|
card.querySelectorAll('[data-feedback]').forEach(btn=>{
|
||||||
|
btn.setAttribute('aria-pressed', 'false');
|
||||||
|
btn.classList.toggle('active-up', false);
|
||||||
|
btn.classList.toggle('active-down', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const badge=document.querySelector('[data-hidden-topics-count]');
|
||||||
|
if (badge) badge.textContent=String((prefs.hidden||[]).length);
|
||||||
|
};
|
||||||
|
sections.forEach(sec=>{
|
||||||
|
const key=sec.dataset.sectionKey;
|
||||||
|
if(Object.prototype.hasOwnProperty.call(collapsed,key)) sec.open=!collapsed[key];
|
||||||
|
sec.addEventListener('toggle',()=>{
|
||||||
|
collapsed[key]=!sec.open;
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (ev)=>{
|
||||||
|
const btn=ev.target.closest('[data-feedback]');
|
||||||
|
if(!btn) return;
|
||||||
|
const card=btn.closest('[data-topic]');
|
||||||
|
if(!card) return;
|
||||||
|
const topic=card.dataset.topic||'other';
|
||||||
|
const id=card.dataset.cardId||'';
|
||||||
|
if(btn.dataset.feedback==='down'){
|
||||||
|
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||||||
|
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||||||
|
hiddenCards.push(id);
|
||||||
|
saveHiddenCards(hiddenCards);
|
||||||
|
setToast(`Right — less <strong>${topic}</strong> like this, and this story’s off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||||||
|
} else {
|
||||||
|
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||||||
|
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||||||
|
hiddenCards.push(id);
|
||||||
|
saveHiddenCards(hiddenCards);
|
||||||
|
setToast(`Right — I’ll look for more <strong>${topic}</strong> like this, and I’ve cleared this story off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||||||
|
}
|
||||||
|
save(prefs); sync();
|
||||||
|
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
|
||||||
|
});
|
||||||
|
document.addEventListener('click',(ev)=>{
|
||||||
|
const undo=ev.target.closest('[data-undo-topic]');
|
||||||
|
if(!undo || !lastAction) return;
|
||||||
|
prefs.hidden=[...(lastAction.prefs.hidden||[])];
|
||||||
|
hiddenCards=[...(lastAction.hiddenCards||[])];
|
||||||
|
save(prefs); saveHiddenCards(hiddenCards); sync();
|
||||||
|
setToast('Undid the last topic/story action.');
|
||||||
|
});
|
||||||
|
document.addEventListener('click',(ev)=>{
|
||||||
|
const btn=ev.target.closest('[data-paperless-action]');
|
||||||
|
if(!btn) return;
|
||||||
|
const card=btn.closest('[data-paperless-id]');
|
||||||
|
if(!card) return;
|
||||||
|
const id=card.dataset.paperlessId;
|
||||||
|
const action=btn.dataset.paperlessAction;
|
||||||
|
btn.disabled=true;
|
||||||
|
postJson('/api/paperless-action',{id,action}).then(()=>{
|
||||||
|
window.location.reload();
|
||||||
|
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
|
||||||
|
});
|
||||||
|
const reset=document.querySelector('[data-reset-topic-prefs]');
|
||||||
|
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; hiddenCards=[]; save(prefs); saveHiddenCards(hiddenCards); sync(); setToast('Reset hidden topics and local story hides.'); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
def metric_card(title:str,value:str,sub:str,cls:str='')->str:
|
||||||
|
return f"<div class='metric {esc(cls)}'><span>{esc(title)}</span><strong>{esc(value)}</strong><em>{esc(sub)}</em></div>"
|
||||||
|
|
||||||
|
def forecast_panel(weather:dict[str,Any])->str:
|
||||||
|
if not weather.get('ok'):
|
||||||
|
return ""
|
||||||
|
daily=weather.get('daily',{}) or {}
|
||||||
|
days=daily.get('time') or []
|
||||||
|
precip=daily.get('precipitation_sum') or []
|
||||||
|
sunrise=daily.get('sunrise') or []
|
||||||
|
sunset=daily.get('sunset') or []
|
||||||
|
cards=[]
|
||||||
|
for idx, day in enumerate(days[:3]):
|
||||||
|
try:
|
||||||
|
label=datetime.fromisoformat(day).strftime('%a')
|
||||||
|
except Exception:
|
||||||
|
label=day
|
||||||
|
rain=precip[idx] if idx < len(precip) else '—'
|
||||||
|
rise=(sunrise[idx][-5:] if idx < len(sunrise) and sunrise[idx] else '—')
|
||||||
|
set_=(sunset[idx][-5:] if idx < len(sunset) and sunset[idx] else '—')
|
||||||
|
cards.append(f"""<article class='forecast-card'>
|
||||||
|
<span class='forecast-day'>{esc(label)}</span>
|
||||||
|
<strong>{esc(str(rain))} mm rain</strong>
|
||||||
|
<em>{esc(rise)} sunrise · {esc(set_)} sunset</em>
|
||||||
|
</article>""")
|
||||||
|
if not cards:
|
||||||
|
return ""
|
||||||
|
return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'forecast',open_by_default=False,extra_class='panel forecast-panel')
|
||||||
|
|
||||||
|
def render()->str:
|
||||||
|
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||||
|
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
|
||||||
|
selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
|
||||||
|
for i in selected:
|
||||||
|
if i.get('url','') in top_urls: continue
|
||||||
|
cat=i.get('category')
|
||||||
|
if cat=='astronomy': groups['space'].append(i)
|
||||||
|
elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i)
|
||||||
|
elif cat=='local': groups['local'].append(i)
|
||||||
|
else: groups['signals'].append(i)
|
||||||
|
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates)
|
||||||
|
now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates)
|
||||||
|
if weather['ok']:
|
||||||
|
cur=weather['current']; daily=weather.get('daily',{})
|
||||||
|
weather_metrics=''.join([
|
||||||
|
metric_card('Clarksville',c_to_f(cur.get('temperature_2m')),weather.get('condition',''), 'weather'),
|
||||||
|
metric_card('Feels / humidity',f"{c_to_f(cur.get('apparent_temperature'))} / {cur.get('relative_humidity_2m','—')}%",'right now','weather'),
|
||||||
|
metric_card('Wind',kmh_to_mph(cur.get('wind_speed_10m')),'Open-Meteo','weather'),
|
||||||
|
metric_card('Rain chance',f"{weather.get('max_precip',0)}%",'next 24h','weather'),
|
||||||
|
metric_card('Sunrise / sunset',f"{(daily.get('sunrise') or ['—'])[0][-5:]} / {(daily.get('sunset') or ['—'])[0][-5:]}",'local time','weather')])
|
||||||
|
else:
|
||||||
|
weather_metrics=metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn')
|
||||||
|
moon_metrics=''.join([
|
||||||
|
metric_card('Moon',f"{moon['emoji']} {moon['name']}",f"{moon['illumination']} lit, age {moon['age']}",'space'),
|
||||||
|
metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')])
|
||||||
|
pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse)
|
||||||
|
forecast_html=forecast_panel(weather)
|
||||||
|
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><main>
|
||||||
|
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Last generated Central · auto-refreshes at :05 / :35</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div></div></header>
|
||||||
|
{collapsible_section('At a glance','Weather and sky cards.',f"<section class='metrics'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=False)}
|
||||||
|
{collapsible_section('Today','Calendar, Donetick, and notes.',f"<section class='today'>{list_panel('Today',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}</section>",'today-overview',open_by_default=True)}
|
||||||
|
{forecast_html}
|
||||||
|
{paperless_review_section()}
|
||||||
|
{collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False)}
|
||||||
|
{section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True)}
|
||||||
|
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:5],'No sky signal right now.',compact=True)}
|
||||||
|
{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)}
|
||||||
|
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:3],'No local signal right now.',compact=True)}
|
||||||
|
{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)}
|
||||||
|
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||||||
|
|
||||||
|
def main()->int:
|
||||||
|
PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True)
|
||||||
|
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||||
|
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos())
|
||||||
|
generated={'generated_at':datetime.now(timezone.utc).isoformat(),'generated_at_local':datetime.now(LOCAL_TZ).isoformat(),'timezone':'America/Chicago','candidate_count':len(candidates),'source_count':len(set(i.get('source','') for i in candidates))}
|
||||||
|
(DATA/'dashboard.json').write_text(json.dumps(generated,indent=2)+'\n')
|
||||||
|
(DATA/'weather.json').write_text(json.dumps(weather,indent=2)+'\n')
|
||||||
|
(DATA/'sky.json').write_text(json.dumps({'moon':moon},indent=2)+'\n')
|
||||||
|
(DATA/'homelab.json').write_text(json.dumps(pulse,indent=2)+'\n')
|
||||||
|
(DATA/'articles.json').write_text(json.dumps(articles,indent=2)+'\n')
|
||||||
|
if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n')
|
||||||
|
(PUBLIC/'index.html').write_text(render(),encoding='utf-8')
|
||||||
|
return 0
|
||||||
|
if __name__=='__main__': raise SystemExit(main())
|
||||||
1
home/doris-dashboard/data/calendar.json
Normal file
1
home/doris-dashboard/data/calendar.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
12
home/doris-dashboard/data/notes.json
Normal file
12
home/doris-dashboard/data/notes.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Dashboard MVP",
|
||||||
|
"body": "To-do and calendar sections are wired as local JSON placeholders until Donetick/calendar integrations are added.",
|
||||||
|
"tag": "setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Paperless notes/tags audit",
|
||||||
|
"body": "Explicit follow-up: review and correct all Paperless notes and tags, not just school-intake edge cases. Keep this visible until the broader cleanup pass is actually finished.",
|
||||||
|
"tag": "paperless"
|
||||||
|
}
|
||||||
|
]
|
||||||
32
home/doris-dashboard/data/paperless_review.example.json
Normal file
32
home/doris-dashboard/data/paperless_review.example.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"title": "scan_2026_05_12",
|
||||||
|
"original_filename": "utility_statement_may.pdf",
|
||||||
|
"added": "2026-05-13T02:14:00Z",
|
||||||
|
"archive_url": "https://paperless.paccoco.com/documents/12345/details",
|
||||||
|
"current": {
|
||||||
|
"correspondent": "CDE Lightband",
|
||||||
|
"document_type": "Statement",
|
||||||
|
"tags": ["inbox"]
|
||||||
|
},
|
||||||
|
"triage": {
|
||||||
|
"summary": "Monthly utility bill from CDE Lightband for $142.17 due on 2026-06-05.",
|
||||||
|
"suggested_title": "CDE Lightband Utility Bill - May 2026",
|
||||||
|
"document_type": "bill",
|
||||||
|
"suggested_correspondent": "CDE Lightband",
|
||||||
|
"suggested_tags": ["paperless-triage", "utility", "bill"],
|
||||||
|
"due_date": "2026-06-05",
|
||||||
|
"needs_review": true,
|
||||||
|
"urgency": "medium",
|
||||||
|
"reason": "Contains a payment due date; safety gate requires human review.",
|
||||||
|
"confidence": "high"
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"forced_review": true,
|
||||||
|
"eligible_for_safe_apply": false,
|
||||||
|
"apply_safe_enabled": false
|
||||||
|
},
|
||||||
|
"queued_at": "2026-05-13T03:22:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
19
home/doris-dashboard/doris-dashboard.service
Normal file
19
home/doris-dashboard/doris-dashboard.service
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Doris Dashboard local LAN web server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=fizzlepoof
|
||||||
|
Group=fizzlepoof
|
||||||
|
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
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
13
home/doris-dashboard/public/style.css
Normal file
13
home/doris-dashboard/public/style.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
:root{color-scheme:dark;--bg:#0c111b;--panel:#151b27;--panel2:#202939;--text:#eef4ff;--muted:#9cabbe;--line:#2d394b;--accent:#8fd3ff;--space:#9f8cff;--home:#4fd1a5;--local:#ffd166;--warn:#ff6b6b;--ok:#5ee787}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,#213455,#0c111b 42rem);color:var(--text)}a{color:inherit}main{max-width:1220px;margin:0 auto;padding:18px}.hero{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:end;margin-bottom:18px}.eyebrow{color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:.78rem;margin:0 0 8px}h1{font-size:clamp(2.2rem,6vw,4.5rem);line-height:.94;margin:0}.sub{color:var(--muted);font-size:1rem;max-width:760px}.hero-actions{display:flex;gap:12px;align-items:center;margin-top:10px;flex-wrap:wrap}.reset-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:8px 12px;font:inherit;cursor:pointer}.reset-btn:hover{border-color:var(--accent)}.status,.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.status div,.metric,.panel,.card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.status strong,.metric strong{display:block;font-size:1.1rem}.status span,.metric span,.metric em,.meta,footer,.empty{color:var(--muted)}.metric{min-height:92px;border-top:4px solid var(--accent)}.metric strong{font-size:1.35rem;margin:6px 0}.metric em{font-style:normal;font-size:.84rem}.metric.weather{border-top-color:var(--local)}.metric.space{border-top-color:var(--space)}.metric.ok{border-top-color:var(--ok)}.metric.warn{border-top-color:var(--warn)}.today{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:12px;margin:0}.panel h2,section h2{margin:0 0 8px}ul{padding-left:18px;margin:0}.panel ul{list-style:none;padding-left:0;display:grid;gap:8px}.panel li{margin:0}section{margin:18px 0}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{min-height:245px;display:flex;flex-direction:column;gap:10px;border-top:4px solid var(--accent)}.card.astronomy{border-top-color:var(--space)}.card.homelab,.card.smart_home,.card.radio{border-top-color:var(--home)}.card.local{border-top-color:var(--local)}.card.security{border-top-color:var(--warn)}.meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.74rem}.meta span{padding:3px 8px;border-radius:999px;background:var(--panel2)}h3{font-size:1rem;line-height:1.2;margin:0}h3 a{text-decoration:none}h3 a:hover{text-decoration:underline}p{line-height:1.35}.why{margin-top:auto;color:#c8d5ea;background:rgba(143,211,255,.08);padding:10px;border-radius:12px;font-size:.88rem}footer{border-top:1px solid var(--line);padding:18px 0 26px}.collapsible{margin:16px 0;border:1px solid var(--line);border-radius:20px;background:rgba(21,27,39,.9);box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px);overflow:hidden}.collapsible>summary{list-style:none;cursor:pointer;padding:14px 16px;display:flex;justify-content:space-between;gap:12px;align-items:center}.collapsible>summary::-webkit-details-marker{display:none}.collapsible-heading h2{margin:0}.collapsible-heading p{margin:4px 0 0;color:var(--muted)}.collapsible-body{padding:0 14px 14px}.collapse-indicator{color:var(--muted);transition:transform .15s ease}.collapsible[open] .collapse-indicator{transform:rotate(180deg)}@media(max-width:1050px){.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr 1fr}.hero{grid-template-columns:1fr}}@media(max-width:650px){main{padding:14px}.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr}.card{min-height:auto}}
|
||||||
|
|
||||||
|
/* Compact article layout: keep the number of stories, reduce scroll debt. */
|
||||||
|
.news-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.compact-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px;box-shadow:0 6px 18px rgba(0,0,0,.14)}.compact-card.astronomy{border-left-color:var(--space)}.compact-card.homelab,.compact-card.smart_home,.compact-card.radio{border-left-color:var(--home)}.compact-card.local{border-left-color:var(--local)}.compact-card.security{border-left-color:var(--warn)}.compact-card .meta{margin-bottom:4px}.compact-card h3{font-size:.98rem;line-height:1.18;margin:0 0 3px}.compact-card p{margin:0;color:#c8d5ea;font-size:.88rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.compact-card .meta span{padding:2px 7px}.compact-card:nth-child(n+7){display:none}section header{display:flex;justify-content:space-between;gap:12px;align-items:end}section header p{margin:0}.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}.feedback-btn{background:transparent;border:1px solid var(--line);border-radius:999px;color:var(--text);padding:2px 8px;font:inherit;cursor:pointer}.feedback-btn:hover{border-color:var(--accent);transform:translateY(-1px)}.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),0 6px 18px rgba(0,0,0,.14)}@media(max-width:650px){.compact-card p{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}section header{display:block}}
|
||||||
|
.feedback-btn[aria-pressed='true']{font-weight:700}.feedback-btn.active-up{border-color:rgba(94,231,135,.65);background:rgba(94,231,135,.12);color:#c8ffd9}.feedback-btn.active-down{border-color:rgba(255,107,107,.6);background:rgba(255,107,107,.12);color:#ffd9d9}.dashboard-toast{position:fixed;right:16px;bottom:16px;z-index:1000;max-width:min(420px,calc(100vw - 24px));background:rgba(12,17,27,.96);border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:16px;padding:12px 14px;color:var(--text);box-shadow:0 14px 34px rgba(0,0,0,.35)}.dashboard-toast[hidden]{display:none}.toast-action{margin-left:10px;background:transparent;border:0;color:var(--accent);font:inherit;font-weight:700;cursor:pointer;padding:0}
|
||||||
|
.muted{color:var(--muted);font-size:.86rem;margin:0 .25rem}.panel-item{background:rgba(32,41,57,.55);border:1px solid var(--line);border-radius:14px;padding:10px 12px}.panel-item-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.panel-item-title{margin-top:2px;font-weight:600;color:#dbe7f7}.panel-item-body{margin-top:4px;color:#c8d5ea;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.panel .empty{padding:12px 0}.donetick-panel{padding:12px 14px}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.task-card{background:rgba(32,41,57,.58);border:1px solid var(--line);border-radius:16px;padding:10px 12px;min-width:0}.task-top{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:4px}.task-due{font-size:.8rem;font-weight:800;letter-spacing:.02em;text-transform:none;color:#dbe7f7}.task-assignee{font-size:.72rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--muted)}.task-name{font-size:1rem;font-weight:700;line-height:1.08;color:var(--text);margin-bottom:6px}.task-subtitle{font-size:.78rem;line-height:1.16;color:var(--muted)}.task-chips{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:5px}.task-chip{display:inline-block;padding:3px 8px;border-radius:999px;color:#fff;font-size:.72rem;font-weight:700;line-height:1.05}.task-card.overdue{border-color:rgba(255,107,107,.55)}.task-card.today{border-color:rgba(255,209,102,.5)}.task-card.tomorrow{border-color:rgba(142,202,230,.45)}.task-card.nodue{opacity:.92}
|
||||||
|
.donetick-panel .task-grid{gap:8px}.task-card{padding:9px 10px;border-radius:14px}.task-top{gap:6px;margin-bottom:3px}.task-state-dot{width:9px;height:9px;border-radius:999px;background:var(--muted);flex:0 0 auto;box-shadow:0 0 0 2px rgba(255,255,255,.03)}.task-card.overdue .task-state-dot{background:var(--warn)}.task-card.today .task-state-dot{background:var(--local)}.task-card.tomorrow .task-state-dot,.task-card.upcoming .task-state-dot{background:#8ecae6}.task-card.nodue .task-state-dot{background:#6c7a90}.task-due{font-size:.76rem;line-height:1.1}.task-name{font-size:.95rem;line-height:1.15;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.task-subtitle{font-size:.74rem;line-height:1.1;opacity:.9;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}.task-chips{gap:4px;margin-bottom:4px}.task-chip{padding:2px 7px;font-size:.68rem}
|
||||||
|
|
||||||
|
/* Forecast */
|
||||||
|
.forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem}
|
||||||
|
|
||||||
|
/* Paperless review queue */
|
||||||
|
.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:1fr;gap:10px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.needs-attention{box-shadow:0 0 0 1px rgba(255,209,102,.25),0 10px 24px rgba(0,0,0,.22)}.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.paperless-card h4{margin:.35rem 0;font-size:.96rem;line-height:1.2}.paperless-card .summary{margin:.2rem 0;color:#d7e3f5;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.paperless-meta,.paperless-reason{margin:.2rem 0;color:var(--muted);font-size:.8rem;line-height:1.2}.paperless-card .link{display:inline-block;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:flex-end}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:5px 9px;font:inherit;cursor:pointer}.paperless-btn.good{border-color:rgba(94,231,135,.45);color:#c8ffd9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked,.paperless-more{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary,.paperless-more summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:720px){.paperless-head{display:block}.paperless-actions{justify-content:flex-start;margin-top:8px}}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
services:
|
services:
|
||||||
kima-hub:
|
kima:
|
||||||
image: ghcr.io/chevron7locked/kima:latest
|
image: ghcr.io/chevron7locked/kima:latest
|
||||||
container_name: kima
|
container_name: kima
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
- PUID=${PUID}
|
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||||
- PGID=${PGID}
|
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||||
- KIMA_MUSIC_PATH=/music
|
- 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:
|
volumes:
|
||||||
- /mnt/docker-ssd/docker/appdata/kima-hub/config:/app/config
|
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
|
||||||
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/app/data
|
- /mnt/unraid/data/media/music:/music
|
||||||
- /mnt/unraid/data/media:/music
|
|
||||||
ports:
|
ports:
|
||||||
- "3333:3333"
|
- "3333:3030"
|
||||||
networks:
|
networks:
|
||||||
- pangolin
|
- pangolin
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
0
meshtastic/scripts/PirateWeatherADV.py
Executable file → Normal file
0
meshtastic/scripts/PirateWeatherADV.py
Executable file → Normal file
0
meshtastic/scripts/mm_wx.py
Executable file → Normal file
0
meshtastic/scripts/mm_wx.py
Executable file → Normal file
6
monitoring/.env.example
Normal file
6
monitoring/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
TZ=America/New_York
|
||||||
|
|
||||||
|
# Grafana
|
||||||
|
GF_ADMIN_USER=admin
|
||||||
|
GF_ADMIN_PASS=CHANGE_ME
|
||||||
|
GF_HOST=grafana.paccoco.com
|
||||||
118
monitoring/DEPLOY.md
Normal file
118
monitoring/DEPLOY.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Phase 6 — Grafana + Prometheus Deployment on PD
|
||||||
|
|
||||||
|
> Adapted from HOMELAB_BUILDOUT_PLAN.md Phase 6, changed host from N.O.M.A.D. to PlausibleDeniability.
|
||||||
|
|
||||||
|
## 1. Scaffold Directories
|
||||||
|
|
||||||
|
SSH into PD (or run locally):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stack directory
|
||||||
|
sudo mkdir -p /opt/monitoring/provisioning/datasources
|
||||||
|
sudo mkdir -p /opt/monitoring/provisioning/dashboards
|
||||||
|
|
||||||
|
# Prometheus TSDB on tank (sequential writes, grows with retention)
|
||||||
|
sudo mkdir -p /mnt/tank/docker/appdata/prometheus
|
||||||
|
sudo chown 65534:65534 /mnt/tank/docker/appdata/prometheus
|
||||||
|
|
||||||
|
# Grafana data on tank
|
||||||
|
sudo mkdir -p /mnt/tank/docker/appdata/grafana
|
||||||
|
sudo chown 472:472 /mnt/tank/docker/appdata/grafana
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Copy Files to PD
|
||||||
|
|
||||||
|
Copy everything from this `monitoring/` folder to `/opt/monitoring/` on PD:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From your local machine (adjust source path as needed)
|
||||||
|
scp -r monitoring/* pd:/opt/monitoring/
|
||||||
|
|
||||||
|
# Or if working directly on PD, copy files into place:
|
||||||
|
# docker-compose.yaml → /opt/monitoring/docker-compose.yaml
|
||||||
|
# prometheus.yml → /opt/monitoring/prometheus.yml
|
||||||
|
# provisioning/ → /opt/monitoring/provisioning/
|
||||||
|
# .env.example → /opt/monitoring/.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Create .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/monitoring
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
# Set GF_ADMIN_PASS=$(openssl rand -hex 16)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Pangolin Configuration
|
||||||
|
|
||||||
|
In Pangolin dashboard, create a new resource using PD's existing Newt:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|--------|--------------------------|
|
||||||
|
| Domain | `grafana.paccoco.com` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Host | `grafana` |
|
||||||
|
| Port | `3000` |
|
||||||
|
|
||||||
|
Since Grafana joins the `pangolin` network, PD's Newt can reach it by container name.
|
||||||
|
|
||||||
|
## 5. Validate & Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/monitoring
|
||||||
|
docker compose --env-file .env config
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Post-Deploy Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All three containers running?
|
||||||
|
docker ps --filter name=prometheus --filter name=grafana --filter name=node-exporter
|
||||||
|
|
||||||
|
# Prometheus scraping targets?
|
||||||
|
curl -s http://10.5.1.6:9090/api/v1/targets | python3 -m json.tool | head -40
|
||||||
|
|
||||||
|
# Grafana UI accessible?
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://10.5.1.6:3000/
|
||||||
|
# Expected: 200 or 302
|
||||||
|
|
||||||
|
# Node exporter metrics flowing?
|
||||||
|
curl -s http://10.5.1.6:9100/metrics | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
> Remember: use `10.5.1.6` not `localhost` for health checks on TrueNAS Scale.
|
||||||
|
|
||||||
|
## 7. Recommended Dashboard Imports
|
||||||
|
|
||||||
|
In Grafana → Dashboards → New → Import → Enter ID:
|
||||||
|
|
||||||
|
| Dashboard | ID | Purpose |
|
||||||
|
|------------------------|------|-----------------------------------|
|
||||||
|
| Node Exporter Full | 1860 | PD system metrics |
|
||||||
|
| Docker Container Stats | 893 | Container resource usage |
|
||||||
|
| Netdata via Prometheus | search | PD and Serenity system metrics |
|
||||||
|
|
||||||
|
## 8. Grafana → n8n Alert Webhook
|
||||||
|
|
||||||
|
In Grafana → Alerting → Contact Points, create:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------------|--------------------------------------------------|
|
||||||
|
| Name | `n8n-alerts` |
|
||||||
|
| Type | Webhook |
|
||||||
|
| URL | `https://n8n.paccoco.com/webhook/grafana-alert` |
|
||||||
|
| HTTP Method | POST |
|
||||||
|
|
||||||
|
Suggested alert rules:
|
||||||
|
- ZFS pool utilization > 85%
|
||||||
|
- Container memory > 90% of limit
|
||||||
|
- Host CPU sustained > 90% for 5 minutes
|
||||||
|
- Disk I/O latency spikes
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Storage:** Prometheus TSDB and Grafana data are on tank (plenty of headroom). 90-day retention is configured. Prometheus writes sequentially so tank performs fine here.
|
||||||
|
- **N.O.M.A.D. scraping:** The N.O.M.A.D. target in prometheus.yml is commented out. Uncomment once Netdata or node-exporter is running on N.O.M.A.D. at 10.5.1.16.
|
||||||
|
- **Image versions:** Prometheus v3.4.0, Grafana 13.0.1, node-exporter v1.9.0 — verify these are still current before deploying.
|
||||||
236
monitoring/deploy.sh
Normal file
236
monitoring/deploy.sh
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 6 — Grafana + Prometheus Deployment on PD
|
||||||
|
# Run this script on PlausibleDeniability as root or with sudo
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
echo "=== Phase 6: Grafana + Prometheus ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- 1. Scaffold directories ----
|
||||||
|
echo "[1/6] Scaffolding directories..."
|
||||||
|
|
||||||
|
mkdir -p /mnt/docker-ssd/docker/compose/monitoring/provisioning/datasources
|
||||||
|
mkdir -p /mnt/docker-ssd/docker/compose/monitoring/provisioning/dashboards
|
||||||
|
|
||||||
|
mkdir -p /mnt/tank/docker/appdata/prometheus
|
||||||
|
chown 65534:65534 /mnt/tank/docker/appdata/prometheus
|
||||||
|
|
||||||
|
mkdir -p /mnt/tank/docker/appdata/grafana
|
||||||
|
chown 472:472 /mnt/tank/docker/appdata/grafana
|
||||||
|
|
||||||
|
echo " Done."
|
||||||
|
|
||||||
|
# ---- 2. Write docker-compose.yaml ----
|
||||||
|
echo "[2/6] Writing docker-compose.yaml..."
|
||||||
|
|
||||||
|
cat > /mnt/docker-ssd/docker/compose/monitoring/docker-compose.yaml << 'COMPOSE'
|
||||||
|
name: monitoring
|
||||||
|
|
||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.4.0
|
||||||
|
container_name: prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--storage.tsdb.retention.time=90d'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
volumes:
|
||||||
|
- /mnt/tank/docker/appdata/prometheus:/prometheus
|
||||||
|
- /mnt/docker-ssd/docker/compose/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:13.0.1
|
||||||
|
container_name: grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ}
|
||||||
|
GF_SECURITY_ADMIN_USER: ${GF_ADMIN_USER}
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASS}
|
||||||
|
GF_SERVER_ROOT_URL: https://${GF_HOST}/
|
||||||
|
volumes:
|
||||||
|
- /mnt/tank/docker/appdata/grafana:/var/lib/grafana
|
||||||
|
- /mnt/docker-ssd/docker/compose/monitoring/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
networks:
|
||||||
|
- pangolin
|
||||||
|
- default
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.9.0
|
||||||
|
container_name: node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9100:9100"
|
||||||
|
command:
|
||||||
|
- '--path.procfs=/host/proc'
|
||||||
|
- '--path.sysfs=/host/sys'
|
||||||
|
- '--path.rootfs=/rootfs'
|
||||||
|
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pangolin:
|
||||||
|
external: true
|
||||||
|
COMPOSE
|
||||||
|
|
||||||
|
echo " Done."
|
||||||
|
|
||||||
|
# ---- 3. Write prometheus.yml ----
|
||||||
|
echo "[3/6] Writing prometheus.yml..."
|
||||||
|
|
||||||
|
cat > /mnt/docker-ssd/docker/compose/monitoring/prometheus.yml << 'PROMCFG'
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
# ------- PlausibleDeniability (local) -------
|
||||||
|
- job_name: 'pd-node'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['node-exporter:9100']
|
||||||
|
labels:
|
||||||
|
host: 'plausible-deniability'
|
||||||
|
|
||||||
|
- job_name: 'pd-netdata'
|
||||||
|
metrics_path: /api/v1/allmetrics
|
||||||
|
params:
|
||||||
|
format: [prometheus]
|
||||||
|
static_configs:
|
||||||
|
- targets: ['10.5.1.6:19999']
|
||||||
|
labels:
|
||||||
|
host: 'plausible-deniability'
|
||||||
|
|
||||||
|
# ------- Serenity -------
|
||||||
|
- job_name: 'serenity-netdata'
|
||||||
|
metrics_path: /api/v1/allmetrics
|
||||||
|
params:
|
||||||
|
format: [prometheus]
|
||||||
|
static_configs:
|
||||||
|
- targets: ['10.5.1.5:19999']
|
||||||
|
labels:
|
||||||
|
host: 'serenity'
|
||||||
|
|
||||||
|
# ------- N.O.M.A.D. -------
|
||||||
|
# Uncomment when Netdata or node-exporter is available on N.O.M.A.D.
|
||||||
|
# - job_name: 'nomad-netdata'
|
||||||
|
# metrics_path: /api/v1/allmetrics
|
||||||
|
# params:
|
||||||
|
# format: [prometheus]
|
||||||
|
# static_configs:
|
||||||
|
# - targets: ['10.5.1.16:19999']
|
||||||
|
# labels:
|
||||||
|
# host: 'nomad'
|
||||||
|
PROMCFG
|
||||||
|
|
||||||
|
echo " Done."
|
||||||
|
|
||||||
|
# ---- 4. Write Grafana provisioning ----
|
||||||
|
echo "[4/6] Writing Grafana provisioning configs..."
|
||||||
|
|
||||||
|
cat > /mnt/docker-ssd/docker/compose/monitoring/provisioning/datasources/prometheus.yml << 'DATASRC'
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: true
|
||||||
|
DATASRC
|
||||||
|
|
||||||
|
cat > /mnt/docker-ssd/docker/compose/monitoring/provisioning/dashboards/dashboards.yml << 'DASHCFG'
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: 'default'
|
||||||
|
orgId: 1
|
||||||
|
folder: ''
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
foldersFromFilesStructure: false
|
||||||
|
DASHCFG
|
||||||
|
|
||||||
|
echo " Done."
|
||||||
|
|
||||||
|
# ---- 5. Generate .env ----
|
||||||
|
echo "[5/6] Generating .env..."
|
||||||
|
|
||||||
|
GF_PASS=$(openssl rand -hex 16)
|
||||||
|
|
||||||
|
cat > /mnt/docker-ssd/docker/compose/monitoring/.env << ENV
|
||||||
|
TZ=America/New_York
|
||||||
|
|
||||||
|
# Grafana
|
||||||
|
GF_ADMIN_USER=admin
|
||||||
|
GF_ADMIN_PASS=${GF_PASS}
|
||||||
|
GF_HOST=grafana.paccoco.com
|
||||||
|
ENV
|
||||||
|
|
||||||
|
echo " Done."
|
||||||
|
echo ""
|
||||||
|
echo " Grafana admin password: ${GF_PASS}"
|
||||||
|
echo " (saved in /mnt/docker-ssd/docker/compose/monitoring/.env)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- 6. Validate and deploy ----
|
||||||
|
echo "[6/6] Validating and deploying..."
|
||||||
|
|
||||||
|
cd /mnt/docker-ssd/docker/compose/monitoring
|
||||||
|
docker compose --env-file .env config > /dev/null 2>&1
|
||||||
|
echo " Compose config valid."
|
||||||
|
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
echo ""
|
||||||
|
echo "=== Deployment complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Waiting 10 seconds for containers to start..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# ---- Post-deploy checks ----
|
||||||
|
echo ""
|
||||||
|
echo "=== Post-Deploy Verification ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- Container status ---"
|
||||||
|
docker ps --filter name=prometheus --filter name=grafana --filter name=node-exporter --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- Prometheus targets ---"
|
||||||
|
curl -s http://10.5.1.6:9090/api/v1/targets 2>/dev/null | python3 -m json.tool 2>/dev/null | head -30 || echo "Prometheus not yet responding (may need a moment)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- Grafana health ---"
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://10.5.1.6:3000/ 2>/dev/null || echo "000")
|
||||||
|
echo "Grafana HTTP status: ${HTTP_CODE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- Node exporter ---"
|
||||||
|
curl -s http://10.5.1.6:9100/metrics 2>/dev/null | head -5 || echo "Node exporter not yet responding"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== All done! ==="
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Add Pangolin resource: grafana.paccoco.com -> http://grafana:3000"
|
||||||
|
echo " 2. Log in at https://grafana.paccoco.com with admin / ${GF_PASS}"
|
||||||
|
echo " 3. Import dashboards: Node Exporter Full (ID 1860), Docker Stats (ID 893)"
|
||||||
|
echo " 4. Set up Grafana -> n8n alert webhook at https://n8n.paccoco.com/webhook/grafana-alert"
|
||||||
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.0.json
Normal file
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.0.json
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:35.930Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"id": "GXtkp4xKka90XrXH",
|
||||||
|
"name": "Grafana Alert \u2192 AI \u2192 Gotify v1.0",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "grafana-alert",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
|
||||||
|
"name": "Grafana Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a => \n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
|
||||||
|
"name": "Parse Alert Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{\n {\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief \u2014 this goes to a phone notification.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this Grafana alert:\\n\\n\" + $json.alertText\n }\n ],\n \"max_tokens\": 200,\n \"temperature\": 0.3\n }\n}}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
|
||||||
|
"name": "LiteLLM Summarize",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\nconst alertData = $('Parse Alert Payload').first().json;\n\nconst title = alertData.hasFiring \n ? `\ud83d\udd25 Alert Firing (${alertData.alertCount})` \n : `\u2705 Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2224,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "DXdYZDfVZecDTaNU",
|
||||||
|
"name": "Gotify account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '30 minutes' LIMIT 1;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
|
||||||
|
},
|
||||||
|
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
|
||||||
|
"name": "Check Should Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "edee2fbd-0b2b-43bf-b542-9d9297d39d66",
|
||||||
|
"leftValue": "={{ $json.suppress }}",
|
||||||
|
"rightValue": false,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "equal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].alertName : \"unknown\" }}', '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].host : \"unknown\" }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Grafana Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Alert Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Alert Payload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Fingerprint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Lookup Dedup",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check Should Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check Should Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Should Notify?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert Dedup Record",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "LiteLLM Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"LiteLLM Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"activeVersionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"versionCounter": 10,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"id": "0ETpkL5jJ5wwgt8k",
|
||||||
|
"name": "monitoring"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "GXtkp4xKka90XrXH",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version ba3a4eab",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.1.json
Normal file
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.1.json
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:35.930Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"id": "GXtkp4xKka90XrXH",
|
||||||
|
"name": "Grafana Alert \u2192 AI \u2192 Gotify v1.1",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "grafana-alert",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
|
||||||
|
"name": "Grafana Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a => \n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
|
||||||
|
"name": "Parse Alert Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{\n {\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief \u2014 this goes to a phone notification.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this Grafana alert:\\n\\n\" + $json.alertText\n }\n ],\n \"max_tokens\": 200,\n \"temperature\": 0.3\n }\n}}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
|
||||||
|
"name": "LiteLLM Summarize",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\nconst alertData = $('Parse Alert Payload').first().json;\n\nconst title = alertData.hasFiring \n ? `\ud83d\udd25 Alert Firing (${alertData.alertCount})` \n : `\u2705 Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2224,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "DXdYZDfVZecDTaNU",
|
||||||
|
"name": "Gotify account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '30 minutes' LIMIT 1;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
|
||||||
|
},
|
||||||
|
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
|
||||||
|
"name": "Check Should Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cc41d77c-b659-4c6f-8e71-96a13dced242",
|
||||||
|
"leftValue": "={{ $json.shouldNotify }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].alertName : \"unknown\" }}', '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].host : \"unknown\" }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Grafana Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Alert Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Alert Payload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Fingerprint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Lookup Dedup",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check Should Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check Should Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Should Notify?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert Dedup Record",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "LiteLLM Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"LiteLLM Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"activeVersionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"versionCounter": 10,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"id": "0ETpkL5jJ5wwgt8k",
|
||||||
|
"name": "monitoring"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "GXtkp4xKka90XrXH",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version ba3a4eab",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
387
n8n-workflows/01-grafana-alert-ai-gotify-v1.2.json
Normal file
387
n8n-workflows/01-grafana-alert-ai-gotify-v1.2.json
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:35.930Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"id": "GXtkp4xKka90XrXH",
|
||||||
|
"name": "Grafana Alert \u2192 AI \u2192 Gotify v1.2",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "grafana-alert",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
|
||||||
|
"name": "Grafana Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a => \n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
|
||||||
|
"name": "Parse Alert Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer sk-REPLACE_WITH_LITELLM_KEY"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{\n {\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief \u2014 this goes to a phone notification.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this Grafana alert:\\n\\n\" + $('Parse Alert Payload').first().json.alertText\n }\n ],\n \"max_tokens\": 200,\n \"temperature\": 0.3\n }\n}}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
|
||||||
|
"name": "LiteLLM Summarize",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\nconst alertData = $('Parse Alert Payload').first().json;\n\nconst title = alertData.hasFiring \n ? `\ud83d\udd25 Alert Firing (${alertData.alertCount})` \n : `\u2705 Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2224,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "DXdYZDfVZecDTaNU",
|
||||||
|
"name": "Gotify account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '30 minutes' LIMIT 1;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
|
||||||
|
},
|
||||||
|
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
|
||||||
|
"name": "Check Should Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cc41d77c-b659-4c6f-8e71-96a13dced242",
|
||||||
|
"leftValue": "={{ $json.shouldNotify }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ $json.dedupAlertName }}', '{{ $json.dedupHost }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\nconst first = (d.parsed || [])[0] || {};\nreturn [{ json: {\n fingerprint: d.fingerprint,\n dedupAlertName: first.alertName || 'unknown',\n dedupHost: first.host || 'unknown'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "54705c76-512d-4479-889f-dc0a3c24d1b3",
|
||||||
|
"name": "Prepare Dedup Data",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Grafana Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Alert Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Alert Payload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Fingerprint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Lookup Dedup",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check Should Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check Should Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Should Notify?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Dedup Data",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "LiteLLM Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"LiteLLM Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Dedup Data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert Dedup Record",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"activeVersionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"versionCounter": 10,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"id": "0ETpkL5jJ5wwgt8k",
|
||||||
|
"name": "monitoring"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "GXtkp4xKka90XrXH",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version ba3a4eab",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
387
n8n-workflows/01-grafana-alert-ai-gotify-v1.3.json
Normal file
387
n8n-workflows/01-grafana-alert-ai-gotify-v1.3.json
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:35.930Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"id": "GXtkp4xKka90XrXH",
|
||||||
|
"name": "Grafana Alert \u2192 AI \u2192 Gotify v1.3",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "grafana-alert",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
|
||||||
|
"name": "Grafana Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a => \n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
|
||||||
|
"name": "Parse Alert Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer {{ $env.LITELLM_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{\n {\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief \u2014 this goes to a phone notification.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this Grafana alert:\\n\\n\" + $('Parse Alert Payload').first().json.alertText\n }\n ],\n \"max_tokens\": 200,\n \"temperature\": 0.3\n }\n}}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
|
||||||
|
"name": "LiteLLM Summarize",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\nconst alertData = $('Parse Alert Payload').first().json;\n\nconst title = alertData.hasFiring \n ? `\ud83d\udd25 Alert Firing (${alertData.alertCount})` \n : `\u2705 Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2224,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "DXdYZDfVZecDTaNU",
|
||||||
|
"name": "Gotify account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "={{ 'SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = \\'' + $json.fingerprint + '\\' AND last_seen > NOW() - INTERVAL \\'30 minutes\\' LIMIT 1;' }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
|
||||||
|
},
|
||||||
|
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
|
||||||
|
"name": "Check Should Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cc41d77c-b659-4c6f-8e71-96a13dced242",
|
||||||
|
"leftValue": "={{ $json.shouldNotify }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "={{ 'INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES (\\'' + $json.fingerprint + '\\', NOW(), \\'' + $json.dedupAlertName + '\\', \\'' + $json.dedupHost + '\\') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();' }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\nconst first = (d.parsed || [])[0] || {};\nreturn [{ json: {\n fingerprint: d.fingerprint,\n dedupAlertName: first.alertName || 'unknown',\n dedupHost: first.host || 'unknown'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "54705c76-512d-4479-889f-dc0a3c24d1b3",
|
||||||
|
"name": "Prepare Dedup Data",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Grafana Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Alert Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Alert Payload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Fingerprint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Lookup Dedup",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check Should Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check Should Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Should Notify?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Dedup Data",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "LiteLLM Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"LiteLLM Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Dedup Data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert Dedup Record",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"activeVersionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
|
||||||
|
"versionCounter": 10,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.660Z",
|
||||||
|
"id": "0ETpkL5jJ5wwgt8k",
|
||||||
|
"name": "monitoring"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"createdAt": "2026-05-07T03:32:32.026Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "GXtkp4xKka90XrXH",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version ba3a4eab",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
250
n8n-workflows/01-grafana-alert-ai-gotify-v1.4.json
Normal file
250
n8n-workflows/01-grafana-alert-ai-gotify-v1.4.json
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
{
|
||||||
|
"name": "Grafana Alert → AI → Gotify v1.4",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "grafana-alert",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
|
||||||
|
"name": "Grafana Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [0, 0],
|
||||||
|
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a =>\n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
|
||||||
|
"name": "Parse Alert Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [224, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [448, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "={{ 'SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = \\'' + $json.fingerprint + '\\' AND last_seen > NOW() - INTERVAL \\'30 minutes\\' LIMIT 1;' }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [672, 0],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
|
||||||
|
},
|
||||||
|
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
|
||||||
|
"name": "Check Should Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [896, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cc41d77c-b659-4c6f-8e71-96a13dced242",
|
||||||
|
"leftValue": "={{ $json.shouldNotify }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [1120, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\nconst first = (d.parsed || [])[0] || {};\nreturn [{ json: {\n fingerprint: d.fingerprint,\n dedupAlertName: first.alertName || 'unknown',\n dedupHost: first.host || 'unknown'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "54705c76-512d-4479-889f-dc0a3c24d1b3",
|
||||||
|
"name": "Prepare Dedup Data",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1344, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "={{ 'INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES (\\'' + $json.fingerprint + '\\', NOW(), \\'' + $json.dedupAlertName + '\\', \\'' + $json.dedupHost + '\\') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();' }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [1568, 0],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Build the LiteLLM request body from Parse Alert Payload data.\n// Doing this in a Code node (instead of inline in the HTTP node) ensures\n// the expression resolves cleanly — same pattern as RSS Digest which is stable.\nconst alertData = $('Parse Alert Payload').first().json;\nconst alertText = alertData.alertText || '(no alert text received)';\n\nreturn [{\n json: {\n aiBody: {\n model: 'medium',\n messages: [\n {\n role: 'system',\n content: 'You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief — this goes to a phone notification.'\n },\n {\n role: 'user',\n content: 'Summarize this Grafana alert:\\n\\n' + alertText\n }\n ],\n max_tokens: 200,\n temperature: 0.3\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "b1c2d3e4-f5a6-7890-abcd-ef1234567890",
|
||||||
|
"name": "Prepare AI Request",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1792, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer {{ $env.LITELLM_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify($json.aiBody) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
|
||||||
|
"name": "LiteLLM Summarize",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [2016, 0],
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst alertData = $('Parse Alert Payload').first().json;\n\nlet summary;\nif (response.error || response.errorMessage) {\n summary = 'AI summary unavailable — check LiteLLM.';\n} else {\n summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\n}\n\nconst title = alertData.hasFiring\n ? `🔥 Alert Firing (${alertData.alertCount})`\n : `✅ Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [2240, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [2464, 0],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "DXdYZDfVZecDTaNU",
|
||||||
|
"name": "Gotify account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Grafana Webhook": {
|
||||||
|
"main": [[{ "node": "Parse Alert Payload", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Parse Alert Payload": {
|
||||||
|
"main": [[{ "node": "Build Fingerprint", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [[{ "node": "Lookup Dedup", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [[{ "node": "Check Should Notify", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Check Should Notify": {
|
||||||
|
"main": [[{ "node": "Should Notify?", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Prepare Dedup Data", "type": "main", "index": 0 }],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Dedup Data": {
|
||||||
|
"main": [[{ "node": "Upsert Dedup Record", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [[{ "node": "Prepare AI Request", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Prepare AI Request": {
|
||||||
|
"main": [[{ "node": "LiteLLM Summarize", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"LiteLLM Summarize": {
|
||||||
|
"main": [[{ "node": "Format Notification", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [[{ "node": "Push to Gotify", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true
|
||||||
|
},
|
||||||
|
"pinData": {}
|
||||||
|
}
|
||||||
394
n8n-workflows/02-rag-ingest-and-query-v1.0.json
Normal file
394
n8n-workflows/02-rag-ingest-and-query-v1.0.json
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T23:25:33.293Z",
|
||||||
|
"createdAt": "2026-05-06T23:25:29.005Z",
|
||||||
|
"id": "gYyOggC4J98keLbj",
|
||||||
|
"name": "RAG Pipeline v1.0",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "rag/ingest",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "31ce8413-d15c-490f-92d1-956ef214e9a3",
|
||||||
|
"name": "Ingest Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "411ad4e3-eb9f-49e2-97c4-2f30e01dd609"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Split incoming text into chunks for embedding\nconst input = $input.first().json.body || $input.first().json;\nconst text = input.text || input.content || '';\nconst source = input.source || 'manual';\nconst metadata = input.metadata || {};\n\nconst CHUNK_SIZE = 500;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n if ((current + ' ' + sentence).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n // Keep overlap\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + sentence;\n } else {\n current = current ? current + ' ' + sentence : sentence;\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: {\n chunk,\n chunkIndex: i,\n totalChunks: chunks.length,\n source,\n metadata\n }\n}));"
|
||||||
|
},
|
||||||
|
"id": "5d5aa0b1-c84b-48f6-9b5e-618cb05e5e61",
|
||||||
|
"name": "Chunk Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": \"{{ $json.chunk }}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"batching": {
|
||||||
|
"batch": {
|
||||||
|
"batchSize": 5,
|
||||||
|
"batchInterval": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "89c692c6-b987-409c-b5c1-3533097d06e3",
|
||||||
|
"name": "Get Embedding",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); });\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "fec59066-958f-4b96-8224-6f4e48ad5abb",
|
||||||
|
"name": "Prepare Qdrant Point",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "9404c8fe-46ae-4d8a-bf35-35ecba4939c0",
|
||||||
|
"name": "Upsert to Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "rag/query",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "1a5c921c-0351-4a5e-a3e4-0aab71313af1",
|
||||||
|
"name": "Query Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
304
|
||||||
|
],
|
||||||
|
"webhookId": "aad4fe04-74b0-4136-80e9-380a7e4db1c2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": \"{{ $json.body.query || $json.query }}\"\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "0a294ba5-a890-4dfa-8ae7-ba25cd0a4534",
|
||||||
|
"name": "Embed Query",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points/search",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"vector\": {{ JSON.stringify($json.data[0].embedding) }},\n \"limit\": 5,\n \"with_payload\": true\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "2cb68891-c57e-4006-b3ac-c290aec28220",
|
||||||
|
"name": "Search Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const searchResults = $input.first().json.result || [];\nconst query = $('Query Webhook').first().json.body?.query || $('Query Webhook').first().json.query || '';\n\nconst context = searchResults.map((r, i) => \n `[${i+1}] (score: ${r.score.toFixed(3)}) ${r.payload.text}`\n).join('\\n\\n');\n\nreturn [{\n json: {\n query,\n context,\n sources: searchResults.map(r => ({\n source: r.payload.source,\n score: r.score,\n text_preview: r.payload.text.substring(0, 100) + '...'\n }))\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "d32ee814-81d0-4ea6-8f28-59632d09b1b9",
|
||||||
|
"name": "Build Context",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a helpful knowledge base assistant. Answer the user's question using ONLY the provided context. If the context doesn't contain enough information, say so. Cite which source numbers you used.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Context:\\n{{ $json.context }}\\n\\nQuestion: {{ $json.query }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "e1360454-8457-40ce-b179-670c182da59e",
|
||||||
|
"name": "LiteLLM Answer",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst contextData = $('Build Context').first().json;\n\nreturn [{\n json: {\n answer: response.choices?.[0]?.message?.content || 'No answer generated',\n sources: contextData.sources,\n query: contextData.query\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "fe16d3a1-0d11-442f-b941-56fb74804601",
|
||||||
|
"name": "Format Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1104,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Ingest Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Chunk Text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Chunk Text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get Embedding",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get Embedding": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Qdrant Point",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Qdrant Point": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert to Qdrant",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Query Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Embed Query",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Embed Query": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Search Qdrant",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Search Qdrant": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Context",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Context": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "LiteLLM Answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"LiteLLM Answer": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "832248e5-106e-4fc4-8ebd-a5d0034e2b14",
|
||||||
|
"activeVersionId": "832248e5-106e-4fc4-8ebd-a5d0034e2b14",
|
||||||
|
"versionCounter": 14,
|
||||||
|
"triggerCount": 2,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"id": "7uuEyQrIlcuruZBk",
|
||||||
|
"name": "rag"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T23:25:29.005Z",
|
||||||
|
"createdAt": "2026-05-06T23:25:29.005Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "gYyOggC4J98keLbj",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version 832248e5",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
412
n8n-workflows/03-paperless-ai-processing-v1.0.json
Normal file
412
n8n-workflows/03-paperless-ai-processing-v1.0.json
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T20:24:47.896Z",
|
||||||
|
"createdAt": "2026-05-07T20:24:40.153Z",
|
||||||
|
"id": "dQ1d5deV4mF0s5eP",
|
||||||
|
"name": "Paperless AI Processing v1.0",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "paperless/new-document",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "3d8bc021-e27f-4f7b-b716-a202d66551be",
|
||||||
|
"name": "Paperless Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "fd1eaa3a-fbf1-4a17-94fd-bb483df602b7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Extract document ID from whatever Paperless sends\nconst data = $input.first().json;\nconst body = data.body || data;\n\n// Try multiple possible field names and formats\nlet docId = body.document_id || body.id || body.doc_id || body.pk;\n\n// If it's a string like '{document_id}' (template not resolved), fall back to latest doc\nif (!docId || typeof docId === 'string' && docId.includes('{')) {\n docId = null;\n}\n\nreturn [{ json: { docId } }];"
|
||||||
|
},
|
||||||
|
"id": "48277e9e-a835-4e12-8608-63e905abe74d",
|
||||||
|
"name": "Extract Document ID",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "=https://paperless.paccoco.com/api/documents/{{ $json.docId ? $json.docId + '/' : '?ordering=-added&page_size=1' }}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "17ed7e6f-ccaa-43d3-9031-9f3d2bb12dec",
|
||||||
|
"name": "Fetch Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst response = $input.first().json;\nconst doc = response.results ? response.results[0] : response;\nif (!doc || !doc.id) {\n throw new Error('No document found in response');\n}\nconst title = doc.title || 'Untitled';\nconst originalFilename = doc.original_file_name || '';\nconst isSchoolIntake = /^school-/i.test(originalFilename) || / \u2014 (draft|final|revision|proposal)$/i.test(title);\nconst content = (doc.content || '').substring(0, 4000);\nreturn [{\n json: {\n documentId: doc.id,\n title,\n originalFilename,\n correspondent: doc.correspondent_name || null,\n content,\n created: doc.created || new Date().toISOString(),\n isSchoolIntake\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "3a026b3b-141b-48b4-a9ae-03998914f45c",
|
||||||
|
"name": "Prepare Content",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a document classification assistant. Analyze the document and return ONLY valid JSON. Do not include markdown. Do not include code fences.\\n\\nReturn this exact JSON structure:\\n{\\n \\\"summary\\\": \\\"short 1-2 sentence summary with no sensitive identifiers\\\",\\n \\\"doc_type\\\": \\\"invoice | receipt | letter | contract | manual | report | medical | tax | military | other\\\",\\n \\\"suggested_tags\\\": [\\\"tag1\\\", \\\"tag2\\\", \\\"tag3\\\"]\\n}\\n\\nIf the document is sensitive, summarize only generally and say sensitive personal identifiers were present and redacted. If the document is not sensitive, provide a normal 1-2 sentence summary. Never mention or summarize sensitive field categories such as SSNs, dates of birth, addresses, phone numbers, financial amounts, ID numbers, service numbers, or exact dates.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"{{ JSON.stringify('Document title: ' + ($json.title || 'Untitled') + '\\nFilename: ' + ($json.originalFilename || '') + '\\nCorrespondent: ' + ($json.correspondent || 'Unknown') + '\\n\\nContent:\\n' + ($json.content || '')).slice(1, -1) }}\"\n }\n ],\n \"max_tokens\": 350,\n \"temperature\": 0.1,\n \"response_format\": {\n \"type\": \"json_object\"\n }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "51374897-d90e-49a2-889e-0476817dd9d8",
|
||||||
|
"name": "AI Classify & Extract",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst docData = $('Prepare Content').first().json;\n\nconst raw = response.choices?.[0]?.message?.content || '{}';\nlet analysis;\n\ntry {\n analysis = JSON.parse(raw);\n} catch (e) {\n analysis = {\n summary: 'Document processed, but AI response could not be parsed cleanly. Sensitive details were not included.',\n doc_type: 'other',\n suggested_tags: []\n };\n}\n\nfunction cleanText(value) {\n return String(value || '')\n .replace(/\\b\\d{3}[- ]?\\d{2}[- ]?\\d{4}\\b/g, '[REDACTED_SSN]')\n .replace(/\\b\\d{8}\\b/g, '[REDACTED_DATE]')\n .replace(/\\b\\d{1,5}\\s+[A-Za-z0-9 .'-]+\\s+(Road|RD|Street|St|Avenue|Ave|Drive|Dr|Lane|Ln|Court|Ct|Branch|Blvd|Boulevard)\\b[^,\\n]*/gi, '[REDACTED_ADDRESS]')\n .replace(/\\b\\d{5}(?:-\\d{4})?\\b/g, '[REDACTED_ZIP]');\n}\n\nconst tags = Array.isArray(analysis.suggested_tags)\n ? analysis.suggested_tags\n .map(t => cleanText(t).trim().toLowerCase())\n .filter(Boolean)\n .map(t => t.replace(/[^a-z0-9 _-]/gi, '').trim())\n .filter(Boolean)\n .slice(0, 5)\n : [];\n\nreturn [\n {\n json: {\n documentId: docData.documentId,\n title: cleanText(docData.title || 'Untitled'),\n summary: cleanText(analysis.summary || 'Document processed.'),\n doc_type: cleanText(analysis.doc_type || 'other').toLowerCase(),\n suggested_tags: tags\n }\n }\n];"
|
||||||
|
},
|
||||||
|
"id": "1f37f096-132b-43b4-bcb4-f5ed094bc9e5",
|
||||||
|
"name": "Parse Analysis",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1104,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "=https://paperless.paccoco.com/api/documents/{{ $json.documentId }}/notes/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"note\": \"{{ JSON.stringify('**AI Analysis**\\nType: ' + ($json.doc_type || 'other') + '\\nSummary: ' + ($json.summary || '') + '\\nSuggested tags: ' + (($json.suggested_tags || []).join(', '))).slice(1, -1) }}\"\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "5597c8c1-436e-4373-bb54-0180be4e7bca",
|
||||||
|
"name": "Add Note to Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1328,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\n\nconst tags = Array.isArray(data.suggested_tags)\n ? data.suggested_tags.join(', ')\n : 'None';\n\nconst message = [\n data.title || 'Untitled',\n '',\n `Type: ${data.doc_type || 'other'}`,\n '',\n `Summary: ${data.summary || 'No summary returned'}`,\n '',\n `Suggested tags: ${tags || 'None'}`\n].join('\\n');\n\nreturn [{\n json: {\n title: '\ud83d\udcc4 New Document Processed',\n message,\n priority: 4\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "c3319471-e3f1-4cd3-aae1-c794f081b1e3",
|
||||||
|
"name": "Format Gotify Document Message",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1328,
|
||||||
|
208
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "193f19dc-2ad6-41af-bd29-a0f5dc0ef1a0",
|
||||||
|
"name": "Send Gotify Message",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1552,
|
||||||
|
208
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "ajvRjvj0QldLQYmo",
|
||||||
|
"name": "Paperless"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"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",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1328,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "skip-school-docs",
|
||||||
|
"leftValue": "={{$json.isSchoolIntake}}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "if-school-intake-skip",
|
||||||
|
"name": "Is School Intake?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
800,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: { skipped: true, reason: 'school_intake_owned_by_school_workflow', documentId: $json.documentId, title: $json.title } }];"
|
||||||
|
},
|
||||||
|
"id": "skip-school-return",
|
||||||
|
"name": "Return School Skip",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1040,
|
||||||
|
-160
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Paperless Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Document ID",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Document ID": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Content",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Content": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Is School Intake?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AI Classify & Extract": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Analysis",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Analysis": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Add Note to Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Format Gotify Document Message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Apply Suggested Tags to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Gotify Document Message": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send Gotify Message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Is School Intake?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return School Skip",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Classify & Extract",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "199d2609-e3e7-46aa-b144-df038eb10a0d",
|
||||||
|
"activeVersionId": "199d2609-e3e7-46aa-b144-df038eb10a0d",
|
||||||
|
"versionCounter": 11,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:46.433Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:46.433Z",
|
||||||
|
"id": "dZUptsMy60GbLHft",
|
||||||
|
"name": "documents"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"id": "NxHjuO4MMd5kdPR7",
|
||||||
|
"name": "paperless"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T20:24:40.153Z",
|
||||||
|
"createdAt": "2026-05-07T20:24:40.153Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "dQ1d5deV4mF0s5eP",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version 199d2609",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"updatedAt": "2026-05-07T20:24:47.896Z",
|
"updatedAt": "2026-05-07T20:24:47.896Z",
|
||||||
"createdAt": "2026-05-07T20:24:40.153Z",
|
"createdAt": "2026-05-07T20:24:40.153Z",
|
||||||
"id": "dQ1d5deV4mF0s5eP",
|
"id": "dQ1d5deV4mF0s5eP",
|
||||||
"name": "Paperless-NGX → AI Processing → Native Gotify + Apply Tags",
|
"name": "Paperless AI Processing v1.0",
|
||||||
"description": null,
|
"description": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
"isArchived": false,
|
"isArchived": false,
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "const response = $input.first().json;\n\n// Handle both single doc response and list response\nconst doc = response.results ? response.results[0] : response;\n\nif (!doc || !doc.id) {\n throw new Error('No document found in response');\n}\n\n// Content is already in the metadata from Paperless OCR\nconst content = (doc.content || '').substring(0, 4000);\n\nreturn [{\n json: {\n documentId: doc.id,\n title: doc.title || 'Untitled',\n originalFilename: doc.original_file_name || '',\n correspondent: doc.correspondent_name || null,\n content: content,\n created: doc.created || new Date().toISOString()\n }\n}];"
|
"jsCode": "\nconst response = $input.first().json;\nconst doc = response.results ? response.results[0] : response;\nif (!doc || !doc.id) {\n throw new Error('No document found in response');\n}\nconst title = doc.title || 'Untitled';\nconst originalFilename = doc.original_file_name || '';\nconst isSchoolIntake = /^school-/i.test(originalFilename) || / \u2014 (draft|final|revision|proposal)$/i.test(title);\nconst content = (doc.content || '').substring(0, 4000);\nreturn [{\n json: {\n documentId: doc.id,\n title,\n originalFilename,\n correspondent: doc.correspondent_name || null,\n content,\n created: doc.created || new Date().toISOString(),\n isSchoolIntake\n }\n}];"
|
||||||
},
|
},
|
||||||
"id": "3a026b3b-141b-48b4-a9ae-03998914f45c",
|
"id": "3a026b3b-141b-48b4-a9ae-03998914f45c",
|
||||||
"name": "Prepare Content",
|
"name": "Prepare Content",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Bearer sk-REPLACE_WITH_LITELLM_KEY"
|
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Content-Type",
|
"name": "Content-Type",
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "const data = $input.first().json;\n\nconst tags = Array.isArray(data.suggested_tags)\n ? data.suggested_tags.join(', ')\n : 'None';\n\nconst message = [\n data.title || 'Untitled',\n '',\n `Type: ${data.doc_type || 'other'}`,\n '',\n `Summary: ${data.summary || 'No summary returned'}`,\n '',\n `Suggested tags: ${tags || 'None'}`\n].join('\\n');\n\nreturn [{\n json: {\n title: '📄 New Document Processed',\n message,\n priority: 4\n }\n}];"
|
"jsCode": "const data = $input.first().json;\n\nconst tags = Array.isArray(data.suggested_tags)\n ? data.suggested_tags.join(', ')\n : 'None';\n\nconst message = [\n data.title || 'Untitled',\n '',\n `Type: ${data.doc_type || 'other'}`,\n '',\n `Summary: ${data.summary || 'No summary returned'}`,\n '',\n `Suggested tags: ${tags || 'None'}`\n].join('\\n');\n\nreturn [{\n json: {\n title: '\ud83d\udcc4 New Document Processed',\n message,\n priority: 4\n }\n}];"
|
||||||
},
|
},
|
||||||
"id": "c3319471-e3f1-4cd3-aae1-c794f081b1e3",
|
"id": "c3319471-e3f1-4cd3-aae1-c794f081b1e3",
|
||||||
"name": "Format Gotify Document Message",
|
"name": "Format Gotify Document Message",
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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",
|
"id": "b0d5423b-dc83-4adf-98c6-d95afa3e5c1b",
|
||||||
"name": "Apply Suggested Tags to Paperless",
|
"name": "Apply Suggested Tags to Paperless",
|
||||||
@@ -198,6 +198,52 @@
|
|||||||
1328,
|
1328,
|
||||||
400
|
400
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "skip-school-docs",
|
||||||
|
"leftValue": "={{$json.isSchoolIntake}}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "if-school-intake-skip",
|
||||||
|
"name": "Is School Intake?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
800,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: { skipped: true, reason: 'school_intake_owned_by_school_workflow', documentId: $json.documentId, title: $json.title } }];"
|
||||||
|
},
|
||||||
|
"id": "skip-school-return",
|
||||||
|
"name": "Return School Skip",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1040,
|
||||||
|
-160
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
@@ -238,7 +284,7 @@
|
|||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "AI Classify & Extract",
|
"node": "Is School Intake?",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
@@ -287,6 +333,24 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"Is School Intake?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return School Skip",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Classify & Extract",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
589
n8n-workflows/04-whisper-transcription-v1.0.json
Normal file
589
n8n-workflows/04-whisper-transcription-v1.0.json
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T23:30:36.237Z",
|
||||||
|
"createdAt": "2026-05-06T23:30:36.237Z",
|
||||||
|
"id": "QEVnqyN4h5kcpVuJ",
|
||||||
|
"name": "Whisper Transcription v1.0",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "transcribe",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "91945dd9-42f8-4343-b131-9e46272f04bf",
|
||||||
|
"name": "Transcription Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "f50eec94-1e56-4189-88ee-51b701c1d889"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:8786/v1/audio/transcriptions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"value": "Systran/faster-whisper-large-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "response_format",
|
||||||
|
"value": "verbose_json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "fc726196-f62d-4ff4-9be6-a84b9dbfff9e",
|
||||||
|
"name": "Whisper Transcribe",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const result = $input.first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\n// Format timestamps\nconst timestamped = segments.map(s => {\n const start = new Date(s.start * 1000).toISOString().substr(11, 8);\n return `[${start}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n text: fullText.trim(),\n timestamped_text: timestamped,\n duration_seconds: Math.round(duration),\n segment_count: segments.length,\n language: result.language || 'en'\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "1b6f017f-849d-456f-9e4b-a0bced1d8e9f",
|
||||||
|
"name": "Format Transcript",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "transcribe/summarize",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "c2f8a2dd-cbbc-4b62-ae24-0f4a95bdf862",
|
||||||
|
"name": "Transcribe + Summarize Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
304
|
||||||
|
],
|
||||||
|
"webhookId": "69b4b2e8-9486-4aa3-b9db-fb8e6bdae98e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:8786/v1/audio/transcriptions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"value": "Systran/faster-whisper-large-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "en"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "ee25bf40-99ef-472b-928d-6ef63516094c",
|
||||||
|
"name": "Whisper Transcribe 2",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a meeting/audio note summarizer. Given a transcription, provide:\\n1. A brief summary (2-3 sentences)\\n2. Key points as a bullet list\\n3. Any action items mentioned\\n\\nRespond in JSON with keys: summary, key_points (array), action_items (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this transcription:\\n\\n{{ $json.text }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "eda24425-dc26-4b4b-858e-9ae0d5e1a5e4",
|
||||||
|
"name": "AI Summarize Audio",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const llmResponse = $input.first().json;\nconst transcript = $('Whisper Transcribe 2').first().json;\nlet analysis;\n\ntry {\n analysis = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n analysis = { summary: llmResponse.choices?.[0]?.message?.content || 'Summary failed', key_points: [], action_items: [] };\n}\n\nreturn [{\n json: {\n transcript: transcript.text || '',\n duration_seconds: Math.round(transcript.duration || 0),\n language: transcript.language || 'en',\n summary: analysis.summary,\n key_points: analysis.key_points || [],\n action_items: analysis.action_items || []\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "cf41669b-8bf7-433b-b390-10765954b0a9",
|
||||||
|
"name": "Format Summary Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const t = $input.first().json;\nconst mins = Math.floor(t.duration_seconds / 60);\nconst secs = t.duration_seconds % 60;\nreturn [{ json: {\n title: '\ud83c\udf99\ufe0f Transcription Complete',\n message: `Duration: ${mins}m ${secs}s \u00b7 ${t.segment_count} segments \u00b7 Language: ${t.language}`,\n priority: 3\n}}];\n"
|
||||||
|
},
|
||||||
|
"id": "5c692355-2a46-4c66-860a-c02a21c73597",
|
||||||
|
"name": "Format Transcription Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "0e96240d-50e2-49f7-be62-4f57451e0543",
|
||||||
|
"name": "Notify Transcription Done",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Whisper"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const t = $('Format Transcript').first().json;\nconst title = `Transcript ${new Date().toLocaleDateString('en-US')}`;\nconst content = `Transcript\\nDate: ${new Date().toISOString()}\\nDuration: ${t.duration_seconds}s\\nLanguage: ${t.language}\\n\\n${t.timestamped_text || t.text}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName: `${title}.txt` }}, json: { title } }];\n"
|
||||||
|
},
|
||||||
|
"id": "da9f4d8d-8c5b-413a-badd-71287dbe0a3c",
|
||||||
|
"name": "Prepare Transcript Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"options": {},
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer ${PAPERLESS_API_TOKEN}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "document",
|
||||||
|
"value": "data",
|
||||||
|
"parameterType": "formBinaryData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "${PAPERLESS_TAG_TRANSCRIPTION}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "7c84da5a-41e2-4b38-9e58-19acfd73f81d",
|
||||||
|
"name": "Save Transcript to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const s = $input.first().json;\nconst mins = Math.floor(s.duration_seconds / 60);\nconst points = (s.key_points || []).slice(0,3).map((p,i) => `${i+1}. ${p}`).join('\\n');\nreturn [{ json: {\n title: '\ud83c\udf99\ufe0f Transcription + Summary Complete',\n message: `${mins}m audio\\n\\nSummary: ${s.summary}\\n\\nKey points:\\n${points}`,\n priority: 4\n}}];\n"
|
||||||
|
},
|
||||||
|
"id": "e55363e4-ff9b-4df4-b40a-fd23227c23e8",
|
||||||
|
"name": "Format Summary Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "b5defea9-35a0-4a22-8769-ed9496a022f6",
|
||||||
|
"name": "Notify Summary Done",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
304
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Whisper"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const s = $('Format Summary Response').first().json;\nconst title = `Audio Summary ${new Date().toLocaleDateString('en-US')}`;\nconst points = (s.key_points || []).map((p,i) => `${i+1}. ${p}`).join('\\n');\nconst actions = (s.action_items || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Audio Transcription & Summary`, `Date: ${new Date().toISOString()}`,\n `Duration: ${Math.floor(s.duration_seconds/60)}m ${s.duration_seconds%60}s`,\n `Language: ${s.language}`, '', `Summary`, `=======`, s.summary,\n '', `Key Points`, `==========`, points,\n ...(actions ? ['', `Action Items`, `============`, actions] : []),\n '', `Full Transcript`, `==============`, s.transcript\n].join('\\n');\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title } }];\n"
|
||||||
|
},
|
||||||
|
"id": "1f31f608-7985-4a9a-afcf-6cf4e44150ba",
|
||||||
|
"name": "Prepare Summary Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
304
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"options": {},
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer ${PAPERLESS_API_TOKEN}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "document",
|
||||||
|
"value": "data",
|
||||||
|
"parameterType": "formBinaryData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "${PAPERLESS_TAG_TRANSCRIPTION}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e9eaee2f-d08e-4a76-af53-ed824ebfe68f",
|
||||||
|
"name": "Save Summary to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
304
|
||||||
|
],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Transcription Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Whisper Transcribe",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Whisper Transcribe": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Transcript",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Transcribe + Summarize Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Whisper Transcribe 2",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Whisper Transcribe 2": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Summarize Audio",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AI Summarize Audio": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Summary Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Transcript": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Transcription Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Transcription Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Notify Transcription Done",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Notify Transcription Done": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Transcript Doc",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Transcript Doc": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Save Transcript to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Summary Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Summary Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Summary Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Notify Summary Done",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Notify Summary Done": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Summary Doc",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Summary Doc": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Save Summary to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "20275daf-b924-4311-9a7f-116a99911764",
|
||||||
|
"activeVersionId": "20275daf-b924-4311-9a7f-116a99911764",
|
||||||
|
"versionCounter": 12,
|
||||||
|
"triggerCount": 2,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:59.719Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:59.719Z",
|
||||||
|
"id": "GLLrEIR8I7eA4ydv",
|
||||||
|
"name": "transcription"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"id": "tuPxDoKFqpVrrQoX",
|
||||||
|
"name": "whisper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T23:30:36.237Z",
|
||||||
|
"createdAt": "2026-05-06T23:30:36.237Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "QEVnqyN4h5kcpVuJ",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version 20275daf",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
406
n8n-workflows/04-whisper-transcription-v1.1.json
Normal file
406
n8n-workflows/04-whisper-transcription-v1.1.json
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
{
|
||||||
|
"name": "Whisper Transcription v1.1",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "transcribe",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "91945dd9-42f8-4343-b131-9e46272f04bf",
|
||||||
|
"name": "Transcription Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [0, 0],
|
||||||
|
"webhookId": "f50eec94-1e56-4189-88ee-51b701c1d889"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.16:8786/v1/audio/transcriptions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"value": "Systran/faster-distil-whisper-small.en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "response_format",
|
||||||
|
"value": "verbose_json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "fc726196-f62d-4ff4-9be6-a84b9dbfff9e",
|
||||||
|
"name": "Whisper Transcribe",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [224, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const result = $input.first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\n// Format timestamps\nconst timestamped = segments.map(s => {\n const start = new Date(s.start * 1000).toISOString().substr(11, 8);\n return `[${start}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n text: fullText.trim(),\n timestamped_text: timestamped,\n duration_seconds: Math.round(duration),\n segment_count: segments.length,\n language: result.language || 'en'\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "1b6f017f-849d-456f-9e4b-a0bced1d8e9f",
|
||||||
|
"name": "Format Transcript",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [448, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "transcribe/summarize",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "c2f8a2dd-cbbc-4b62-ae24-0f4a95bdf862",
|
||||||
|
"name": "Transcribe + Summarize Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [0, 304],
|
||||||
|
"webhookId": "69b4b2e8-9486-4aa3-b9db-fb8e6bdae98e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.16:8786/v1/audio/transcriptions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"value": "Systran/faster-distil-whisper-small.en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "en"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "ee25bf40-99ef-472b-928d-6ef63516094c",
|
||||||
|
"name": "Whisper Transcribe 2",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [224, 304]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $env.LITELLM_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a meeting/audio note summarizer. Given a transcription, provide:\\n1. A brief summary (2-3 sentences)\\n2. Key points as a bullet list\\n3. Any action items mentioned\\n\\nRespond in JSON with keys: summary, key_points (array), action_items (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this transcription:\\n\\n{{ $json.text }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "eda24425-dc26-4b4b-858e-9ae0d5e1a5e4",
|
||||||
|
"name": "AI Summarize Audio",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [448, 304]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const llmResponse = $input.first().json;\nconst transcript = $('Whisper Transcribe 2').first().json;\nlet analysis;\n\ntry {\n analysis = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n analysis = { summary: llmResponse.choices?.[0]?.message?.content || 'Summary failed', key_points: [], action_items: [] };\n}\n\nreturn [{\n json: {\n transcript: transcript.text || '',\n duration_seconds: Math.round(transcript.duration || 0),\n language: transcript.language || 'en',\n summary: analysis.summary,\n key_points: analysis.key_points || [],\n action_items: analysis.action_items || []\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "cf41669b-8bf7-433b-b390-10765954b0a9",
|
||||||
|
"name": "Format Summary Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [672, 304]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const t = $input.first().json;\nconst mins = Math.floor(t.duration_seconds / 60);\nconst secs = t.duration_seconds % 60;\nreturn [{ json: {\n title: '🎙️ Transcription Complete',\n message: `Duration: ${mins}m ${secs}s · ${t.segment_count} segments · Language: ${t.language}`,\n priority: 3\n}}];\n"
|
||||||
|
},
|
||||||
|
"id": "5c692355-2a46-4c66-860a-c02a21c73597",
|
||||||
|
"name": "Format Transcription Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [672, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "0e96240d-50e2-49f7-be62-4f57451e0543",
|
||||||
|
"name": "Notify Transcription Done",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [896, 0],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Whisper"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const t = $('Format Transcript').first().json;\nconst title = `Transcript ${new Date().toLocaleDateString('en-US')}`;\nconst content = `Transcript\\nDate: ${new Date().toISOString()}\\nDuration: ${t.duration_seconds}s\\nLanguage: ${t.language}\\n\\n${t.timestamped_text || t.text}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName: `${title}.txt` }}, json: { title } }];\n"
|
||||||
|
},
|
||||||
|
"id": "da9f4d8d-8c5b-413a-badd-71287dbe0a3c",
|
||||||
|
"name": "Prepare Transcript Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1120, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Token {{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "document",
|
||||||
|
"inputDataFieldName": "document"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "={{ $env.PAPERLESS_TAG_TRANSCRIPTION }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "7c84da5a-41e2-4b38-9e58-19acfd73f81d",
|
||||||
|
"name": "Save Transcript to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [1344, 0],
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const s = $input.first().json;\nconst mins = Math.floor(s.duration_seconds / 60);\nconst points = (s.key_points || []).slice(0,3).map((p,i) => `${i+1}. ${p}`).join('\\n');\nreturn [{ json: {\n title: '🎙️ Transcription + Summary Complete',\n message: `${mins}m audio\\n\\nSummary: ${s.summary}\\n\\nKey points:\\n${points}`,\n priority: 4\n}}];\n"
|
||||||
|
},
|
||||||
|
"id": "e55363e4-ff9b-4df4-b40a-fd23227c23e8",
|
||||||
|
"name": "Format Summary Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [896, 304]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "b5defea9-35a0-4a22-8769-ed9496a022f6",
|
||||||
|
"name": "Notify Summary Done",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [1120, 304],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Whisper"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const s = $('Format Summary Response').first().json;\nconst title = `Audio Summary ${new Date().toLocaleDateString('en-US')}`;\nconst points = (s.key_points || []).map((p,i) => `${i+1}. ${p}`).join('\\n');\nconst actions = (s.action_items || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Audio Transcription & Summary`, `Date: ${new Date().toISOString()}`,\n `Duration: ${Math.floor(s.duration_seconds/60)}m ${s.duration_seconds%60}s`,\n `Language: ${s.language}`, '', `Summary`, `=======`, s.summary,\n '', `Key Points`, `==========`, points,\n ...(actions ? ['', `Action Items`, `============`, actions] : []),\n '', `Full Transcript`, `==============`, s.transcript\n].join('\\n');\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title } }];\n"
|
||||||
|
},
|
||||||
|
"id": "1f31f608-7985-4a9a-afcf-6cf4e44150ba",
|
||||||
|
"name": "Prepare Summary Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1344, 304]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Token {{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "document",
|
||||||
|
"inputDataFieldName": "document"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "={{ $env.PAPERLESS_TAG_TRANSCRIPTION }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e9eaee2f-d08e-4a76-af53-ed824ebfe68f",
|
||||||
|
"name": "Save Summary to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [1568, 304],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Transcription Webhook": {
|
||||||
|
"main": [[{ "node": "Whisper Transcribe", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Whisper Transcribe": {
|
||||||
|
"main": [[{ "node": "Format Transcript", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Transcribe + Summarize Webhook": {
|
||||||
|
"main": [[{ "node": "Whisper Transcribe 2", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Whisper Transcribe 2": {
|
||||||
|
"main": [[{ "node": "AI Summarize Audio", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"AI Summarize Audio": {
|
||||||
|
"main": [[{ "node": "Format Summary Response", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Transcript": {
|
||||||
|
"main": [[{ "node": "Format Transcription Notify", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Transcription Notify": {
|
||||||
|
"main": [[{ "node": "Notify Transcription Done", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Notify Transcription Done": {
|
||||||
|
"main": [[{ "node": "Prepare Transcript Doc", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Prepare Transcript Doc": {
|
||||||
|
"main": [[{ "node": "Save Transcript to Paperless", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Summary Response": {
|
||||||
|
"main": [[{ "node": "Format Summary Notify", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Summary Notify": {
|
||||||
|
"main": [[{ "node": "Notify Summary Done", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Notify Summary Done": {
|
||||||
|
"main": [[{ "node": "Prepare Summary Doc", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Prepare Summary Doc": {
|
||||||
|
"main": [[{ "node": "Save Summary to Paperless", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{ "name": "transcription" },
|
||||||
|
{ "name": "whisper" },
|
||||||
|
{ "name": "ai" }
|
||||||
|
]
|
||||||
|
}
|
||||||
357
n8n-workflows/05-paperless-to-rag-v1.0.json
Normal file
357
n8n-workflows/05-paperless-to-rag-v1.0.json
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:48:49.355Z",
|
||||||
|
"createdAt": "2026-05-07T00:48:49.355Z",
|
||||||
|
"id": "4YKnRQ9rOiQCeYEU",
|
||||||
|
"name": "Paperless \u2192 RAG v1.0",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "paperless/rag-ingest",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a86d7025-bbf0-4c1b-a59c-ad254ddc913e",
|
||||||
|
"name": "Paperless RAG Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "f2eccecb-9935-425b-be6a-f57fe4473f34"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst body = data.body || data;\n\nlet docId = body.document_id || body.id || body.doc_id || body.pk;\n\nif (!docId || typeof docId === 'string' && docId.includes('{')) {\n docId = null;\n}\n\nreturn [{ json: { docId } }];"
|
||||||
|
},
|
||||||
|
"id": "8f6af849-bdd9-466e-af81-84d057fd7ca1",
|
||||||
|
"name": "Extract Document ID",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "=https://paperless.paccoco.com/api/documents/{{ $json.docId ? $json.docId + '/' : '?ordering=-added&page_size=1' }}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "0b53d951-40cd-4ef0-ab81-3dea73f8b1d7",
|
||||||
|
"name": "Fetch Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst doc = response.results ? response.results[0] : response;\n\nif (!doc || !doc.id) {\n throw new Error('No document found in response');\n}\n\nconst content = doc.content || '';\nconst title = doc.title || 'Untitled';\nconst correspondent = doc.correspondent_name || 'Unknown';\nconst created = doc.created || new Date().toISOString();\nconst docType = doc.document_type_name || 'unknown';\nconst tags = (doc.tags || []).join(', ');\n\n// Build rich text for RAG with metadata context\nconst ragText = `Document: ${title}\\nCorrespondent: ${correspondent}\\nType: ${docType}\\nTags: ${tags}\\nDate: ${created}\\n\\n${content}`;\n\nreturn [{\n json: {\n documentId: doc.id,\n title,\n ragText,\n source: `paperless-doc-${doc.id}`,\n metadata: {\n paperless_id: doc.id,\n title,\n correspondent,\n document_type: docType,\n created\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "e3d506bc-1677-4825-b3d3-f6187156da22",
|
||||||
|
"name": "Prepare RAG Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Split text into chunks for embedding\nconst input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 500;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n if ((current + ' ' + sentence).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + sentence;\n } else {\n current = current ? current + ' ' + sentence : sentence;\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: {\n chunk,\n chunkIndex: i,\n totalChunks: chunks.length,\n source,\n metadata\n }\n}));"
|
||||||
|
},
|
||||||
|
"id": "9a523501-3adb-41a3-b963-b6bb5adb25fe",
|
||||||
|
"name": "Chunk Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": \"{{ $json.chunk }}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"batching": {
|
||||||
|
"batch": {
|
||||||
|
"batchSize": 5,
|
||||||
|
"batchInterval": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "0b631ea4-b11f-4533-960f-f5161ac798f3",
|
||||||
|
"name": "Get Embedding",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1104,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ad9dc677-e3a2-464e-a82e-2aa4d8b9b042",
|
||||||
|
"name": "Prepare Qdrant Point",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1328,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "e977b536-d470-4da0-a970-794a858e8aa9",
|
||||||
|
"name": "Upsert to Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1552,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const ragText = $('Prepare RAG Text').first().json;\nconst chunks = $('Chunk Text').first().json;\nreturn [{\n json: {\n title: '\ud83d\udcda Document Added to RAG',\n message: `${ragText.title} has been chunked and ingested into the knowledge base (${chunks.totalChunks || '?'} chunks)`,\n priority: 3\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "fmt05-0001-0000-0000-000000000005",
|
||||||
|
"name": "Format Gotify Message",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1520,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "ccdee208-b8f8-4ad4-a20c-94c4735d2e3d",
|
||||||
|
"name": "Notify via Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Paperless RAG"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Paperless RAG Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Document ID",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Document ID": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare RAG Text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare RAG Text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Chunk Text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Chunk Text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get Embedding",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get Embedding": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Qdrant Point",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Qdrant Point": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert to Qdrant",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert to Qdrant": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Gotify Message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Gotify Message": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Notify via Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "3c20b423-8708-4540-8bf9-8db4880335a5",
|
||||||
|
"activeVersionId": "3c20b423-8708-4540-8bf9-8db4880335a5",
|
||||||
|
"versionCounter": 12,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"id": "NxHjuO4MMd5kdPR7",
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"id": "7uuEyQrIlcuruZBk",
|
||||||
|
"name": "rag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:48:49.355Z",
|
||||||
|
"createdAt": "2026-05-07T00:48:49.355Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "4YKnRQ9rOiQCeYEU",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version 3c20b423",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
588
n8n-workflows/06-web-scrape-summarize-v1.0.json
Normal file
588
n8n-workflows/06-web-scrape-summarize-v1.0.json
Normal file
File diff suppressed because one or more lines are too long
210
n8n-workflows/07-git-commit-summarizer-v1.0.json
Normal file
210
n8n-workflows/07-git-commit-summarizer-v1.0.json
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:43.226Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:43.226Z",
|
||||||
|
"id": "CFYWbBRx9RPf4HjS",
|
||||||
|
"name": "Git Commit \u2192 AI Summary \u2192 Gotify v1.0",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "git/push",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "fe377ac9-adcf-427d-a096-679f5469f8d8",
|
||||||
|
"name": "Git Push Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "d1d31942-6fe8-48a2-aab7-7eefaf800420"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst body = data.body || data;\n\n// Works with Gitea, Forgejo, GitHub, and GitLab webhook payloads\nconst repo = body.repository?.full_name || body.repository?.name || body.project?.path_with_namespace || 'unknown-repo';\nconst branch = (body.ref || '').replace('refs/heads/', '');\nconst pusher = body.pusher?.name || body.pusher?.login || body.user_name || body.sender?.login || 'unknown';\n\n// Extract commits\nconst commits = (body.commits || []).map(c => ({\n id: (c.id || c.sha || '').substring(0, 7),\n message: (c.message || '').split('\\n')[0],\n author: c.author?.name || c.author?.username || 'unknown',\n added: (c.added || []).length,\n modified: (c.modified || []).length,\n removed: (c.removed || []).length\n}));\n\nconst commitSummary = commits.map(c =>\n `${c.id}: ${c.message} (by ${c.author}) [+${c.added} ~${c.modified} -${c.removed}]`\n).join('\\n');\n\nreturn [{\n json: {\n repo,\n branch,\n pusher,\n commitCount: commits.length,\n commitSummary,\n compareUrl: body.compare_url || body.compare || ''\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "8dda4f14-9563-490f-89b3-aef80f64ba0b",
|
||||||
|
"name": "Parse Git Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"light\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a git commit summarizer. Given a list of commits pushed to a repo, write a 1-2 sentence summary of what changed. Be concise and technical. Focus on the purpose of the changes, not just listing files.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Repository: {{ $json.repo }}\\nBranch: {{ $json.branch }}\\nPushed by: {{ $json.pusher }}\\n\\nCommits ({{ $json.commitCount }}):\\n{{ $json.commitSummary }}\"\n }\n ],\n \"max_tokens\": 150,\n \"temperature\": 0.2\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "c6b2b71b-2612-4ef7-8561-8adccbfa9be8",
|
||||||
|
"name": "AI Summarize Commits",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.first().json;\nconst gitData = $('Parse Git Payload').first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not summarize';\n\nconst title = `\ud83d\udd00 ${gitData.repo}:${gitData.branch} (${gitData.commitCount} commit${gitData.commitCount !== 1 ? 's' : ''})`;\n\nreturn [{\n json: {\n title,\n message: `${summary}\\n\\nPushed by: ${gitData.pusher}`,\n priority: 3\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "94f5c120-0ae6-4a23-9556-d4f564f9f967",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "4f731683-574c-4a39-84fe-025280193ba1",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Git Commits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Git Push Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Git Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Git Payload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Summarize Commits",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AI Summarize Commits": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "61fca126-b5dd-4425-b0ee-c621d3ed58af",
|
||||||
|
"activeVersionId": "61fca126-b5dd-4425-b0ee-c621d3ed58af",
|
||||||
|
"versionCounter": 12,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:41.640Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:41.640Z",
|
||||||
|
"id": "36CVfQVO41QQyOmh",
|
||||||
|
"name": "notifications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:41.638Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:41.638Z",
|
||||||
|
"id": "2oumYE52ygvhrieZ",
|
||||||
|
"name": "git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:43.226Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:43.226Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "CFYWbBRx9RPf4HjS",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version 61fca126",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
532
n8n-workflows/08-class-recording-rag-v1.0.json
Normal file
532
n8n-workflows/08-class-recording-rag-v1.0.json
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"createdAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"id": "91I278E72FAaQenD",
|
||||||
|
"name": "Class Recording \u2192 RAG v1.0",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "class/upload",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e475f17b-8560-4376-852e-a613ba6b300e",
|
||||||
|
"name": "Class Upload Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "d0ee8226-db56-477e-8a3f-572a9129aea9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst body = data.body || data;\n\n// Class name comes from:\n// 1. Form field 'class_name' in multipart upload\n// 2. Query parameter ?class=CS101\n// 3. Header X-Class-Name\nconst className = body.class_name || data.headers?.['x-class-name'] || data.query?.class || 'unclassified';\n\n// Lecture title from:\n// 1. Form field 'lecture_title'\n// 2. Query param ?title=...\n// 3. Header X-Lecture-Title\n// 4. Form field 'filename' (original filename)\nconst rawTitle = body.lecture_title || data.headers?.['x-lecture-title'] || data.query?.title || body.filename || 'Untitled Lecture';\nconst lectureTitle = rawTitle.replace(/\\.wav$/i, '').replace(/[-_]/g, ' ');\n\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n uploadedAt: new Date().toISOString()\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "24e4a726-ab50-4767-a144-1751616c781d",
|
||||||
|
"name": "Extract Class Info",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:8786/v1/audio/transcriptions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"value": "Systran/faster-whisper-large-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "response_format",
|
||||||
|
"value": "verbose_json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 600000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fcd1062d-8f60-4214-85ee-f53b914abcbb",
|
||||||
|
"name": "Whisper Transcribe",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const result = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\nconst timestamped = segments.map(s => {\n const mins = Math.floor(s.start / 60);\n const secs = Math.floor(s.start % 60);\n return `[${mins}:${String(secs).padStart(2, '0')}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n fullText: fullText.trim(),\n timestampedText: timestamped,\n durationMinutes: Math.round(duration / 60),\n segmentCount: segments.length\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ecf09ad2-327e-4a82-90f3-95da5590e0ac",
|
||||||
|
"name": "Format Transcript",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 3000 chars):\\n{{ $json.fullText.substring(0, 3000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "e7407d06-f7f4-4105-a817-055d4703d59d",
|
||||||
|
"name": "AI Extract Notes",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const llmResponse = $input.first().json;\nconst transcriptData = $('Format Transcript').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${transcriptData.className}`,\n `Lecture: ${transcriptData.lectureTitle}`,\n `Duration: ${transcriptData.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n transcriptData.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: transcriptData.className,\n classSlug: transcriptData.classSlug,\n lectureTitle: transcriptData.lectureTitle,\n durationMinutes: transcriptData.durationMinutes,\n notes,\n ragText,\n source: `class-${transcriptData.classSlug}-${Date.now()}`,\n metadata: {\n class_name: transcriptData.className,\n class_slug: transcriptData.classSlug,\n lecture_title: transcriptData.lectureTitle,\n duration_minutes: transcriptData.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "71b0807d-03ba-45b2-b75b-b6fbeee5687e",
|
||||||
|
"name": "Prepare for RAG",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1104,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 500;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n if ((current + ' ' + sentence).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + sentence;\n } else {\n current = current ? current + ' ' + sentence : sentence;\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: {\n chunk,\n chunkIndex: i,\n totalChunks: chunks.length,\n source,\n metadata\n }\n}));"
|
||||||
|
},
|
||||||
|
"id": "9e4bbe70-72b0-4955-848f-0d1b62ae94a4",
|
||||||
|
"name": "Chunk Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1328,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": \"{{ $json.chunk }}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"batching": {
|
||||||
|
"batch": {
|
||||||
|
"batchSize": 5,
|
||||||
|
"batchInterval": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "4ae1ab11-c76f-4478-84e0-7bcdfb20b83b",
|
||||||
|
"name": "Get Embedding",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1552,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "d4232b02-23fc-4cbd-ac2d-2fdbfbe9583a",
|
||||||
|
"name": "Prepare Qdrant Point",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "845a2aa0-2546-431d-96e8-b36875d1fe7d",
|
||||||
|
"name": "Upsert to Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1984,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `\ud83c\udf93 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "fmt08-0001-0000-0000-000000000008",
|
||||||
|
"name": "Format Gotify Message",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1968,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "5b31b660-c414-40d1-82a5-d68511203133",
|
||||||
|
"name": "Notify via Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2208,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Class Recordings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defs = Object.entries(notes.definitions || {}).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n].join('\\n');\nconst title = `${rag.className} \u2014 ${rag.lectureTitle}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title, className: rag.className } }];\n"
|
||||||
|
},
|
||||||
|
"id": "95aea3a7-3b15-4196-9e4a-dc8f6ed8d49e",
|
||||||
|
"name": "Prepare Notes Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2208,
|
||||||
|
-200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"options": {},
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer ${PAPERLESS_API_TOKEN}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "document",
|
||||||
|
"value": "data",
|
||||||
|
"parameterType": "formBinaryData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "${PAPERLESS_TAG_LECTURE_NOTES}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "c902a153-ab26-4518-af2a-585f0b72fe6c",
|
||||||
|
"name": "Save Notes to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
2432,
|
||||||
|
-200
|
||||||
|
],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Class Upload Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Class Info",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Class Info": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Whisper Transcribe",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Whisper Transcribe": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Transcript",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Transcript": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Extract Notes",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AI Extract Notes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare for RAG",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare for RAG": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Chunk Text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Chunk Text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get Embedding",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get Embedding": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Qdrant Point",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Qdrant Point": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert to Qdrant",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert to Qdrant": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Gotify Message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Prepare Notes Doc",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Gotify Message": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Notify via Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Notes Doc": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Save Notes to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf",
|
||||||
|
"activeVersionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf",
|
||||||
|
"versionCounter": 11,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"id": "Q9lCYtCOuy9XmeyL",
|
||||||
|
"name": "school"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"id": "tuPxDoKFqpVrrQoX",
|
||||||
|
"name": "whisper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"id": "7uuEyQrIlcuruZBk",
|
||||||
|
"name": "rag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"createdAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "91I278E72FAaQenD",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.484Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.721Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version d64293a7",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
538
n8n-workflows/08-class-recording-rag-v1.1.json
Normal file
538
n8n-workflows/08-class-recording-rag-v1.1.json
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"createdAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"id": "91I278E72FAaQenD",
|
||||||
|
"name": "Class Recording → RAG v1.1",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "class/upload",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e475f17b-8560-4376-852e-a613ba6b300e",
|
||||||
|
"name": "Class Upload Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "d0ee8226-db56-477e-8a3f-572a9129aea9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst body = data.body || data;\n\n// Class name comes from:\n// 1. Form field 'class_name' in multipart upload\n// 2. Query parameter ?class=CS101\n// 3. Header X-Class-Name\nconst className = body.class_name || data.headers?.['x-class-name'] || data.query?.class || 'unclassified';\n\n// Lecture title from:\n// 1. Form field 'lecture_title'\n// 2. Query param ?title=...\n// 3. Header X-Lecture-Title\n// 4. Form field 'filename' (original filename)\nconst rawTitle = body.lecture_title || data.headers?.['x-lecture-title'] || data.query?.title || body.filename || 'Untitled Lecture';\nconst lectureTitle = rawTitle.replace(/\\.wav$/i, '').replace(/[-_]/g, ' ');\n\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n uploadedAt: new Date().toISOString()\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "24e4a726-ab50-4767-a144-1751616c781d",
|
||||||
|
"name": "Extract Class Info",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.16:8786/v1/audio/transcriptions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"value": "Systran/faster-distil-whisper-small.en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "response_format",
|
||||||
|
"value": "verbose_json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 600000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fcd1062d-8f60-4214-85ee-f53b914abcbb",
|
||||||
|
"name": "Whisper Transcribe",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const result = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\nconst timestamped = segments.map(s => {\n const mins = Math.floor(s.start / 60);\n const secs = Math.floor(s.start % 60);\n return `[${mins}:${String(secs).padStart(2, '0')}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n fullText: fullText.trim(),\n timestampedText: timestamped,\n durationMinutes: Math.round(duration / 60),\n segmentCount: segments.length\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ecf09ad2-327e-4a82-90f3-95da5590e0ac",
|
||||||
|
"name": "Format Transcript",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $env.LITELLM_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 3000 chars):\\n{{ $json.fullText.substring(0, 3000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "e7407d06-f7f4-4105-a817-055d4703d59d",
|
||||||
|
"name": "AI Extract Notes",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const llmResponse = $input.first().json;\nconst transcriptData = $('Format Transcript').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${transcriptData.className}`,\n `Lecture: ${transcriptData.lectureTitle}`,\n `Duration: ${transcriptData.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n transcriptData.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: transcriptData.className,\n classSlug: transcriptData.classSlug,\n lectureTitle: transcriptData.lectureTitle,\n durationMinutes: transcriptData.durationMinutes,\n notes,\n ragText,\n source: `class-${transcriptData.classSlug}-${Date.now()}`,\n metadata: {\n class_name: transcriptData.className,\n class_slug: transcriptData.classSlug,\n lecture_title: transcriptData.lectureTitle,\n duration_minutes: transcriptData.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "71b0807d-03ba-45b2-b75b-b6fbeee5687e",
|
||||||
|
"name": "Prepare for RAG",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1104,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 500;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n if ((current + ' ' + sentence).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + sentence;\n } else {\n current = current ? current + ' ' + sentence : sentence;\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: {\n chunk,\n chunkIndex: i,\n totalChunks: chunks.length,\n source,\n metadata\n }\n}));"
|
||||||
|
},
|
||||||
|
"id": "9e4bbe70-72b0-4955-848f-0d1b62ae94a4",
|
||||||
|
"name": "Chunk Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1328,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": \"{{ $json.chunk }}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"batching": {
|
||||||
|
"batch": {
|
||||||
|
"batchSize": 5,
|
||||||
|
"batchInterval": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "4ae1ab11-c76f-4478-84e0-7bcdfb20b83b",
|
||||||
|
"name": "Get Embedding",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1552,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "d4232b02-23fc-4cbd-ac2d-2fdbfbe9583a",
|
||||||
|
"name": "Prepare Qdrant Point",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "845a2aa0-2546-431d-96e8-b36875d1fe7d",
|
||||||
|
"name": "Upsert to Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1984,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `🎓 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "fmt08-0001-0000-0000-000000000008",
|
||||||
|
"name": "Format Gotify Message",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1968,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "5b31b660-c414-40d1-82a5-d68511203133",
|
||||||
|
"name": "Notify via Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2208,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Class Recordings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defs = Object.entries(notes.definitions || {}).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n].join('\\n');\nconst title = `${rag.className} — ${rag.lectureTitle}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title, className: rag.className } }];\n"
|
||||||
|
},
|
||||||
|
"id": "95aea3a7-3b15-4196-9e4a-dc8f6ed8d49e",
|
||||||
|
"name": "Prepare Notes Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2208,
|
||||||
|
-200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Token {{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "document",
|
||||||
|
"inputDataFieldName": "document"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "={{ $env.PAPERLESS_TAG_LECTURE_NOTES }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "c902a153-ab26-4518-af2a-585f0b72fe6c",
|
||||||
|
"name": "Save Notes to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
2432,
|
||||||
|
-200
|
||||||
|
],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Class Upload Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Class Info",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Class Info": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Whisper Transcribe",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Whisper Transcribe": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Transcript",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Transcript": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Extract Notes",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AI Extract Notes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare for RAG",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare for RAG": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Chunk Text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Chunk Text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get Embedding",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get Embedding": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Qdrant Point",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Qdrant Point": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert to Qdrant",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert to Qdrant": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Gotify Message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Prepare Notes Doc",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Gotify Message": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Notify via Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Notes Doc": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Save Notes to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf",
|
||||||
|
"activeVersionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf",
|
||||||
|
"versionCounter": 11,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"id": "Q9lCYtCOuy9XmeyL",
|
||||||
|
"name": "school"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"id": "tuPxDoKFqpVrrQoX",
|
||||||
|
"name": "whisper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"id": "7uuEyQrIlcuruZBk",
|
||||||
|
"name": "rag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"createdAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "91I278E72FAaQenD",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.638Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.820Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version d64293a7",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
538
n8n-workflows/08-class-recording-rag-v1.2.json
Normal file
538
n8n-workflows/08-class-recording-rag-v1.2.json
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"createdAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"id": "91I278E72FAaQenD",
|
||||||
|
"name": "Class Recording \u2192 RAG v1.2",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "class/upload",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e475f17b-8560-4376-852e-a613ba6b300e",
|
||||||
|
"name": "Class Upload Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "d0ee8226-db56-477e-8a3f-572a9129aea9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst body = data.body || data;\n\n// Class name comes from:\n// 1. Form field 'class_name' in multipart upload\n// 2. Query parameter ?class=CS101\n// 3. Header X-Class-Name\nconst className = body.class_name || data.headers?.['x-class-name'] || data.query?.class || 'unclassified';\n\n// Lecture title from:\n// 1. Form field 'lecture_title'\n// 2. Query param ?title=...\n// 3. Header X-Lecture-Title\n// 4. Form field 'filename' (original filename)\nconst rawTitle = body.lecture_title || data.headers?.['x-lecture-title'] || data.query?.title || body.filename || 'Untitled Lecture';\nconst lectureTitle = rawTitle.replace(/\\.(wav|mp3|m4a|ogg)$/i, '').replace(/[-_]/g, ' ');\n\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n uploadedAt: new Date().toISOString()\n },\n binary: $input.first().binary\n}];"
|
||||||
|
},
|
||||||
|
"id": "24e4a726-ab50-4767-a144-1751616c781d",
|
||||||
|
"name": "Extract Class Info",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.16:8786/v1/audio/transcriptions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"value": "Systran/faster-distil-whisper-small.en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "response_format",
|
||||||
|
"value": "verbose_json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 3600000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fcd1062d-8f60-4214-85ee-f53b914abcbb",
|
||||||
|
"name": "Whisper Transcribe",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const result = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\nconst timestamped = segments.map(s => {\n const mins = Math.floor(s.start / 60);\n const secs = Math.floor(s.start % 60);\n return `[${mins}:${String(secs).padStart(2, '0')}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n fullText: fullText.trim(),\n timestampedText: timestamped,\n durationMinutes: Math.round(duration / 60),\n segmentCount: segments.length\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "ecf09ad2-327e-4a82-90f3-95da5590e0ac",
|
||||||
|
"name": "Format Transcript",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $env.LITELLM_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 3000 chars):\\n{{ $json.fullText.substring(0, 3000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "e7407d06-f7f4-4105-a817-055d4703d59d",
|
||||||
|
"name": "AI Extract Notes",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const llmResponse = $input.first().json;\nconst transcriptData = $('Format Transcript').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${transcriptData.className}`,\n `Lecture: ${transcriptData.lectureTitle}`,\n `Duration: ${transcriptData.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n transcriptData.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: transcriptData.className,\n classSlug: transcriptData.classSlug,\n lectureTitle: transcriptData.lectureTitle,\n durationMinutes: transcriptData.durationMinutes,\n fullText: transcriptData.fullText,\n notes,\n ragText,\n source: `class-${transcriptData.classSlug}-${Date.now()}`,\n metadata: {\n class_name: transcriptData.className,\n class_slug: transcriptData.classSlug,\n lecture_title: transcriptData.lectureTitle,\n duration_minutes: transcriptData.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "71b0807d-03ba-45b2-b75b-b6fbeee5687e",
|
||||||
|
"name": "Prepare for RAG",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1104,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 400;\nconst MAX_CHUNK = 600;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n // If a single sentence exceeds MAX_CHUNK, split it by words first\n const parts = [];\n if (sentence.length > MAX_CHUNK) {\n const words = sentence.split(' ');\n let part = '';\n for (const word of words) {\n if ((part + ' ' + word).length > CHUNK_SIZE && part.length > 0) {\n parts.push(part.trim());\n part = word;\n } else {\n part = part ? part + ' ' + word : word;\n }\n }\n if (part.trim()) parts.push(part.trim());\n } else {\n parts.push(sentence);\n }\n\n for (const part of parts) {\n if ((current + ' ' + part).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + part;\n } else {\n current = current ? current + ' ' + part : part;\n }\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: {\n chunk,\n chunkIndex: i,\n totalChunks: chunks.length,\n source,\n metadata\n }\n}));"
|
||||||
|
},
|
||||||
|
"id": "9e4bbe70-72b0-4955-848f-0d1b62ae94a4",
|
||||||
|
"name": "Chunk Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1328,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": {{ JSON.stringify($json.chunk) }}\n}",
|
||||||
|
"options": {
|
||||||
|
"batching": {
|
||||||
|
"batch": {
|
||||||
|
"batchSize": 5,
|
||||||
|
"batchInterval": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "4ae1ab11-c76f-4478-84e0-7bcdfb20b83b",
|
||||||
|
"name": "Get Embedding",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1552,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "d4232b02-23fc-4cbd-ac2d-2fdbfbe9583a",
|
||||||
|
"name": "Prepare Qdrant Point",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "845a2aa0-2546-431d-96e8-b36875d1fe7d",
|
||||||
|
"name": "Upsert to Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1984,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `\ud83c\udf93 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "fmt08-0001-0000-0000-000000000008",
|
||||||
|
"name": "Format Gotify Message",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1968,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "5b31b660-c414-40d1-82a5-d68511203133",
|
||||||
|
"name": "Notify via Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2208,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Class Recordings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defList = notes.definitions || [];\nconst defs = Array.isArray(defList)\n ? defList.map(d => `${d.term}: ${d.definition}`).join('\\n')\n : Object.entries(defList).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n '', `Full Transcript`, `================`, rag.fullText || '',\n].join('\\n');\nconst title = `${rag.className} \u2014 ${rag.lectureTitle}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title, className: rag.className } }];\n"
|
||||||
|
},
|
||||||
|
"id": "95aea3a7-3b15-4196-9e4a-dc8f6ed8d49e",
|
||||||
|
"name": "Prepare Notes Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2208,
|
||||||
|
-200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Token {{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "document",
|
||||||
|
"inputDataFieldName": "document"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "={{ $env.PAPERLESS_TAG_LECTURE_NOTES }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "c902a153-ab26-4518-af2a-585f0b72fe6c",
|
||||||
|
"name": "Save Notes to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
2432,
|
||||||
|
-200
|
||||||
|
],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Class Upload Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Class Info",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Class Info": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Whisper Transcribe",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Whisper Transcribe": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Transcript",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Transcript": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Extract Notes",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AI Extract Notes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare for RAG",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare for RAG": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Chunk Text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Chunk Text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get Embedding",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get Embedding": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Qdrant Point",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Qdrant Point": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert to Qdrant",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert to Qdrant": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Gotify Message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Prepare Notes Doc",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Gotify Message": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Notify via Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Notes Doc": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Save Notes to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf",
|
||||||
|
"activeVersionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf",
|
||||||
|
"versionCounter": 11,
|
||||||
|
"triggerCount": 1,
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"id": "Q9lCYtCOuy9XmeyL",
|
||||||
|
"name": "school"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:59.716Z",
|
||||||
|
"id": "tuPxDoKFqpVrrQoX",
|
||||||
|
"name": "whisper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:33.284Z",
|
||||||
|
"id": "7uuEyQrIlcuruZBk",
|
||||||
|
"name": "rag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"createdAt": "2026-05-06T22:38:51.678Z",
|
||||||
|
"id": "S9FxzVfHLsHmyzyt",
|
||||||
|
"name": "ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shared": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"createdAt": "2026-05-07T01:16:50.340Z",
|
||||||
|
"role": "workflow:owner",
|
||||||
|
"workflowId": "91I278E72FAaQenD",
|
||||||
|
"projectId": "dxCRnBdX5uJizCGa",
|
||||||
|
"project": {
|
||||||
|
"updatedAt": "2026-05-06T01:10:37.638Z",
|
||||||
|
"createdAt": "2026-05-06T01:08:07.820Z",
|
||||||
|
"id": "dxCRnBdX5uJizCGa",
|
||||||
|
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
|
||||||
|
"type": "personal",
|
||||||
|
"icon": null,
|
||||||
|
"description": null,
|
||||||
|
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versionMetadata": {
|
||||||
|
"name": "Version d64293a7",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
262
n8n-workflows/09-class-transcript-ingest-v1.0.json
Normal file
262
n8n-workflows/09-class-transcript-ingest-v1.0.json
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
{
|
||||||
|
"name": "Class Transcript Ingest v1.0",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "class/ingest",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000001",
|
||||||
|
"name": "Class Ingest Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [0, 0],
|
||||||
|
"webhookId": "class-ingest-rocinante-v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={ \"status\": \"accepted\" }",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000002",
|
||||||
|
"name": "Respond Accepted",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [224, -200]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const body = $input.first().json.body || $input.first().json;\n\nconst className = body.class_name || 'unclassified';\nconst lectureTitle = body.lecture_title || 'Untitled Lecture';\nconst fullText = body.transcript || '';\nconst timestampedText = body.timestamped_transcript || fullText;\nconst durationMinutes = Math.round((body.duration_seconds || 0) / 60);\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n fullText,\n timestampedText,\n durationMinutes\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000003",
|
||||||
|
"name": "Extract Class Info",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [224, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" },
|
||||||
|
{ "name": "Authorization", "value": "=Bearer {{ $env.LITELLM_API_KEY }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 4000 chars):\\n{{ $json.fullText.substring(0, 4000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000004",
|
||||||
|
"name": "AI Extract Notes",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [448, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const llmResponse = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${classInfo.className}`,\n `Lecture: ${classInfo.lectureTitle}`,\n `Duration: ${classInfo.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n classInfo.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n durationMinutes: classInfo.durationMinutes,\n fullText: classInfo.fullText,\n notes,\n ragText,\n source: `class-${classInfo.classSlug}-${Date.now()}`,\n metadata: {\n class_name: classInfo.className,\n class_slug: classInfo.classSlug,\n lecture_title: classInfo.lectureTitle,\n duration_minutes: classInfo.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000005",
|
||||||
|
"name": "Prepare for RAG",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [672, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 400;\nconst MAX_CHUNK = 600;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n // If a single sentence exceeds MAX_CHUNK, split it by words first\n const parts = [];\n if (sentence.length > MAX_CHUNK) {\n const words = sentence.split(' ');\n let part = '';\n for (const word of words) {\n if ((part + ' ' + word).length > CHUNK_SIZE && part.length > 0) {\n parts.push(part.trim());\n part = word;\n } else {\n part = part ? part + ' ' + word : word;\n }\n }\n if (part.trim()) parts.push(part.trim());\n } else {\n parts.push(sentence);\n }\n\n for (const part of parts) {\n if ((current + ' ' + part).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + part;\n } else {\n current = current ? current + ' ' + part : part;\n }\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: { chunk, chunkIndex: i, totalChunks: chunks.length, source, metadata }\n}));"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000006",
|
||||||
|
"name": "Chunk Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [896, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": {{ JSON.stringify($json.chunk) }}\n}",
|
||||||
|
"options": {
|
||||||
|
"batching": {
|
||||||
|
"batch": { "batchSize": 5, "batchInterval": 500 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000007",
|
||||||
|
"name": "Get Embedding",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [1120, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000008",
|
||||||
|
"name": "Prepare Qdrant Point",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1344, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000009",
|
||||||
|
"name": "Upsert to Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [1568, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `🎓 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000010",
|
||||||
|
"name": "Format Gotify Message",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1552, -200]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": { "contentType": "text/plain" }
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000011",
|
||||||
|
"name": "Notify via Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [1776, -200],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Class Recordings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defList = notes.definitions || [];\nconst defs = Array.isArray(defList)\n ? defList.map(d => `${d.term}: ${d.definition}`).join('\\n')\n : Object.entries(defList).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\n\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n '', `Full Transcript`, `================`, rag.fullText || '',\n].join('\\n');\n\nconst title = `${rag.className} — ${rag.lectureTitle}`;\nreturn [{\n binary: {\n document: {\n data: Buffer.from(content, 'utf-8').toString('base64'),\n mimeType: 'text/plain',\n fileName: `${title}.txt`\n }\n },\n json: { title, className: rag.className }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000012",
|
||||||
|
"name": "Prepare Notes Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1776, 200]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Authorization", "value": "=Token {{ $env.PAPERLESS_API_TOKEN }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "parameterType": "formBinaryData", "name": "document", "inputDataFieldName": "document" },
|
||||||
|
{ "name": "title", "value": "={{ $json.title }}" },
|
||||||
|
{ "name": "tags[]", "value": "={{ $env.PAPERLESS_TAG_LECTURE_NOTES }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": { "responseFormat": "text" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000013",
|
||||||
|
"name": "Save Notes to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [2000, 200],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Class Ingest Webhook": {
|
||||||
|
"main": [[
|
||||||
|
{ "node": "Extract Class Info", "type": "main", "index": 0 },
|
||||||
|
{ "node": "Respond Accepted", "type": "main", "index": 0 }
|
||||||
|
]]
|
||||||
|
},
|
||||||
|
"Extract Class Info": {
|
||||||
|
"main": [[{ "node": "AI Extract Notes", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"AI Extract Notes": {
|
||||||
|
"main": [[{ "node": "Prepare for RAG", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Prepare for RAG": {
|
||||||
|
"main": [[{ "node": "Chunk Text", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Chunk Text": {
|
||||||
|
"main": [[{ "node": "Get Embedding", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Get Embedding": {
|
||||||
|
"main": [[{ "node": "Prepare Qdrant Point", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Prepare Qdrant Point": {
|
||||||
|
"main": [[{ "node": "Upsert to Qdrant", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Upsert to Qdrant": {
|
||||||
|
"main": [[
|
||||||
|
{ "node": "Format Gotify Message", "type": "main", "index": 0 },
|
||||||
|
{ "node": "Prepare Notes Doc", "type": "main", "index": 0 }
|
||||||
|
]]
|
||||||
|
},
|
||||||
|
"Format Gotify Message": {
|
||||||
|
"main": [[{ "node": "Notify via Gotify", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Prepare Notes Doc": {
|
||||||
|
"main": [[{ "node": "Save Notes to Paperless", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{ "name": "school" },
|
||||||
|
{ "name": "rag" },
|
||||||
|
{ "name": "ai" }
|
||||||
|
]
|
||||||
|
}
|
||||||
423
n8n-workflows/09-class-transcript-ingest-v1.1.json
Normal file
423
n8n-workflows/09-class-transcript-ingest-v1.1.json
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
{
|
||||||
|
"name": "Class Transcript Ingest v1.1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "class/ingest",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000001",
|
||||||
|
"name": "Class Ingest Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "class-ingest-rocinante-v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={ \"status\": \"accepted\" }",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000002",
|
||||||
|
"name": "Respond Accepted",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
-200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const body = $input.first().json.body || $input.first().json;\n\nconst className = body.class_name || 'unclassified';\nconst lectureTitle = body.lecture_title || 'Untitled Lecture';\nconst fullText = body.transcript || '';\nconst timestampedText = body.timestamped_transcript || fullText;\nconst durationMinutes = Math.round((body.duration_seconds || 0) / 60);\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n fullText,\n timestampedText,\n durationMinutes\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000003",
|
||||||
|
"name": "Extract Class Info",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $env.LITELLM_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 4000 chars):\\n{{ $json.fullText.substring(0, 4000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000004",
|
||||||
|
"name": "AI Extract Notes",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const llmResponse = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${classInfo.className}`,\n `Lecture: ${classInfo.lectureTitle}`,\n `Duration: ${classInfo.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n classInfo.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n durationMinutes: classInfo.durationMinutes,\n fullText: classInfo.fullText,\n notes,\n ragText,\n source: `class-${classInfo.classSlug}-${Date.now()}`,\n metadata: {\n class_name: classInfo.className,\n class_slug: classInfo.classSlug,\n lecture_title: classInfo.lectureTitle,\n duration_minutes: classInfo.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000005",
|
||||||
|
"name": "Prepare for RAG",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 400;\nconst MAX_CHUNK = 600;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n // If a single sentence exceeds MAX_CHUNK, split it by words first\n const parts = [];\n if (sentence.length > MAX_CHUNK) {\n const words = sentence.split(' ');\n let part = '';\n for (const word of words) {\n if ((part + ' ' + word).length > CHUNK_SIZE && part.length > 0) {\n parts.push(part.trim());\n part = word;\n } else {\n part = part ? part + ' ' + word : word;\n }\n }\n if (part.trim()) parts.push(part.trim());\n } else {\n parts.push(sentence);\n }\n\n for (const part of parts) {\n if ((current + ' ' + part).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + part;\n } else {\n current = current ? current + ' ' + part : part;\n }\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: { chunk, chunkIndex: i, totalChunks: chunks.length, source, metadata }\n}));"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000006",
|
||||||
|
"name": "Chunk Text",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:11434/v1/embeddings",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": {{ JSON.stringify($json.chunk) }}\n}",
|
||||||
|
"options": {
|
||||||
|
"batching": {
|
||||||
|
"batch": {
|
||||||
|
"batchSize": 5,
|
||||||
|
"batchInterval": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000007",
|
||||||
|
"name": "Get Embedding",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000008",
|
||||||
|
"name": "Prepare Qdrant Point",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://10.5.1.6:6333/collections/knowledge_base/points",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000009",
|
||||||
|
"name": "Upsert to Qdrant",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `\ud83c\udf93 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000010",
|
||||||
|
"name": "Format Gotify Message",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1552,
|
||||||
|
-200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000011",
|
||||||
|
"name": "Notify via Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1776,
|
||||||
|
-200
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Class Recordings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defList = notes.definitions || [];\nconst defs = Array.isArray(defList)\n ? defList.map(d => `${d.term}: ${d.definition}`).join('\\n')\n : Object.entries(defList).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\n\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n '', `Full Transcript`, `================`, rag.fullText || '',\n].join('\\n');\n\nconst title = `${rag.className} \u2014 ${rag.lectureTitle}`;\nreturn [{\n binary: {\n document: {\n data: Buffer.from(content, 'utf-8').toString('base64'),\n mimeType: 'text/plain',\n fileName: `${title}.txt`\n }\n },\n json: { title, className: rag.className }\n}];"
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000012",
|
||||||
|
"name": "Prepare Notes Doc",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1776,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Token {{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "document",
|
||||||
|
"inputDataFieldName": "document"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags[]",
|
||||||
|
"value": "={{ $env.PAPERLESS_TAG_LECTURE_NOTES }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-0001-0001-0001-000000000013",
|
||||||
|
"name": "Save Notes to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
2000,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Class Ingest Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Class Info",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Respond Accepted",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Class Info": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Extract Notes",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AI Extract Notes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare for RAG",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare for RAG": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Chunk Text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Chunk Text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get Embedding",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get Embedding": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Qdrant Point",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Qdrant Point": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert to Qdrant",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert to Qdrant": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Gotify Message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Prepare Notes Doc",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Gotify Message": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Notify via Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Notes Doc": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Save Notes to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "school"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ai"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
292
n8n-workflows/14-paperless-inbox-reminder-v1.0.json
Normal file
292
n8n-workflows/14-paperless-inbox-reminder-v1.0.json
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless Inbox Reminder v1.0",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "weeks",
|
||||||
|
"weeksInterval": 1,
|
||||||
|
"triggerAtDay": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"triggerAtHour": 9,
|
||||||
|
"triggerAtMinute": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "41404371-29ed-4660-9989-a5cbd890555b",
|
||||||
|
"name": "Every Monday 9 AM",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer ={{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "is_tagged",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page_size",
|
||||||
|
"value": "25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ordering",
|
||||||
|
"value": "created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fields",
|
||||||
|
"value": "id,title,created,document_type,tags"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "fdcd5728-6d27-445f-961b-18f204297469",
|
||||||
|
"name": "Fetch Inbox Docs",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst docs = data.results || [];\nconst total = data.count || 0;\n\n// Oldest 5 for the reminder body\nconst oldest = docs.slice(0, 5).map(d => {\n const date = d.created ? d.created.substring(0, 10) : 'unknown date';\n return ` \u2022 ${d.title || 'Untitled'} (added ${date})`;\n});\n\nreturn [{\n json: {\n total,\n hasItems: total > 0,\n oldestList: oldest.join('\\n'),\n remaining: Math.max(0, total - 5)\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "8fbe6be0-1ed3-4126-8f21-c3f4efb161c6",
|
||||||
|
"name": "Parse Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "efdd4f81-1976-4d26-a565-d110bfeba1df",
|
||||||
|
"leftValue": "={{ $json.hasItems }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "equal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e24344a1-e857-462c-b96a-842169e83a6d",
|
||||||
|
"name": "Has Inbox Items?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\nconst extra = d.remaining > 0 ? `\\n \u2026 and ${d.remaining} more` : '';\nconst body = `${d.total} untagged document${d.total !== 1 ? 's' : ''} in Paperless inbox:\\n${d.oldestList}${extra}\\n\\nhttps://paperless.paccoco.com`;\nreturn [{ json: { title: '\ud83d\udce5 Paperless Inbox Needs Attention', message: body, priority: 5 } }];"
|
||||||
|
},
|
||||||
|
"id": "897af9c9-4429-445f-8160-a03a14a318a3",
|
||||||
|
"name": "Format Reminder",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: { title: '\u2705 Paperless Inbox Clear', message: 'No untagged documents \u2014 inbox is clean.', priority: 1 } }];"
|
||||||
|
},
|
||||||
|
"id": "bd8fe88e-7876-45dd-8344-983674fba79d",
|
||||||
|
"name": "Format Clear",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "74860e7f-42e3-448f-9e46-5cfb6f12cdc2",
|
||||||
|
"name": "Push to Gotify (Reminder)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Reminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "b1dbd39b-87c3-43ec-9cec-f63eabbd0fa5",
|
||||||
|
"name": "Push to Gotify (Clear)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Reminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every Monday 9 AM": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Inbox Docs",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Inbox Docs": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has Inbox Items?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has Inbox Items?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Reminder",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Clear",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Reminder": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify (Reminder)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Clear": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify (Clear)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "reminders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheduled"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
293
n8n-workflows/14-paperless-inbox-reminder-v1.1.json
Normal file
293
n8n-workflows/14-paperless-inbox-reminder-v1.1.json
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless Inbox Reminder v1.1",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "weeks",
|
||||||
|
"weeksInterval": 1,
|
||||||
|
"triggerAtDay": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"triggerAtHour": 9,
|
||||||
|
"triggerAtMinute": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "41404371-29ed-4660-9989-a5cbd890555b",
|
||||||
|
"name": "Every Monday 9 AM",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer ={{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "is_tagged",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page_size",
|
||||||
|
"value": "25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ordering",
|
||||||
|
"value": "created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fields",
|
||||||
|
"value": "id,title,created,document_type,tags"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "fdcd5728-6d27-445f-961b-18f204297469",
|
||||||
|
"name": "Fetch Inbox Docs",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst docs = data.results || [];\nconst total = data.count || 0;\n\n// Oldest 5 for the reminder body\nconst oldest = docs.slice(0, 5).map(d => {\n const date = d.created ? d.created.substring(0, 10) : 'unknown date';\n return ` \u2022 ${d.title || 'Untitled'} (added ${date})`;\n});\n\nreturn [{\n json: {\n total,\n hasItems: total > 0,\n oldestList: oldest.join('\\n'),\n remaining: Math.max(0, total - 5)\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "8fbe6be0-1ed3-4126-8f21-c3f4efb161c6",
|
||||||
|
"name": "Parse Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "b206599d-0c79-4a32-9fb9-91e1c41dcff2",
|
||||||
|
"leftValue": "={{ $json.hasItemsStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e24344a1-e857-462c-b96a-842169e83a6d",
|
||||||
|
"name": "Has Inbox Items?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\nconst extra = d.remaining > 0 ? `\\n \u2026 and ${d.remaining} more` : '';\nconst body = `${d.total} untagged document${d.total !== 1 ? 's' : ''} in Paperless inbox:\\n${d.oldestList}${extra}\\n\\nhttps://paperless.paccoco.com`;\nreturn [{ json: { title: '\ud83d\udce5 Paperless Inbox Needs Attention', message: body, priority: 5 } }];"
|
||||||
|
},
|
||||||
|
"id": "897af9c9-4429-445f-8160-a03a14a318a3",
|
||||||
|
"name": "Format Reminder",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: { title: '\u2705 Paperless Inbox Clear', message: 'No untagged documents \u2014 inbox is clean.', priority: 1 } }];"
|
||||||
|
},
|
||||||
|
"id": "bd8fe88e-7876-45dd-8344-983674fba79d",
|
||||||
|
"name": "Format Clear",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "74860e7f-42e3-448f-9e46-5cfb6f12cdc2",
|
||||||
|
"name": "Push to Gotify (Reminder)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Reminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "b1dbd39b-87c3-43ec-9cec-f63eabbd0fa5",
|
||||||
|
"name": "Push to Gotify (Clear)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Reminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every Monday 9 AM": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Inbox Docs",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Inbox Docs": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has Inbox Items?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has Inbox Items?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Reminder",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Clear",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Reminder": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify (Reminder)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Clear": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify (Clear)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "reminders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheduled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "2b80579b-8d89-42d5-8b8b-9137f59ea206"
|
||||||
|
}
|
||||||
293
n8n-workflows/14-paperless-inbox-reminder-v1.2.json
Normal file
293
n8n-workflows/14-paperless-inbox-reminder-v1.2.json
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless Inbox Reminder v1.2",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "weeks",
|
||||||
|
"weeksInterval": 1,
|
||||||
|
"triggerAtDay": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"triggerAtHour": 9,
|
||||||
|
"triggerAtMinute": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "41404371-29ed-4660-9989-a5cbd890555b",
|
||||||
|
"name": "Every Monday 9 AM",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Token {{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "is_tagged",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page_size",
|
||||||
|
"value": "25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ordering",
|
||||||
|
"value": "created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fields",
|
||||||
|
"value": "id,title,created,document_type,tags"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "fdcd5728-6d27-445f-961b-18f204297469",
|
||||||
|
"name": "Fetch Inbox Docs",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst docs = data.results || [];\nconst total = data.count || 0;\n\n// Oldest 5 for the reminder body\nconst oldest = docs.slice(0, 5).map(d => {\n const date = d.created ? d.created.substring(0, 10) : 'unknown date';\n return ` • ${d.title || 'Untitled'} (added ${date})`;\n});\n\nreturn [{\n json: {\n total,\n hasItems: total > 0,\n hasItemsStr: total > 0 ? 'yes' : 'no',\n oldestList: oldest.join('\\n'),\n remaining: Math.max(0, total - 5)\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "8fbe6be0-1ed3-4126-8f21-c3f4efb161c6",
|
||||||
|
"name": "Parse Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "b206599d-0c79-4a32-9fb9-91e1c41dcff2",
|
||||||
|
"leftValue": "={{ $json.hasItemsStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "e24344a1-e857-462c-b96a-842169e83a6d",
|
||||||
|
"name": "Has Inbox Items?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\nconst extra = d.remaining > 0 ? `\\n … and ${d.remaining} more` : '';\nconst body = `${d.total} untagged document${d.total !== 1 ? 's' : ''} in Paperless inbox:\\n${d.oldestList}${extra}\\n\\nhttps://paperless.paccoco.com`;\nreturn [{ json: { title: '📥 Paperless Inbox Needs Attention', message: body, priority: 5 } }];"
|
||||||
|
},
|
||||||
|
"id": "897af9c9-4429-445f-8160-a03a14a318a3",
|
||||||
|
"name": "Format Reminder",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: { title: '✅ Paperless Inbox Clear', message: 'No untagged documents — inbox is clean.', priority: 1 } }];"
|
||||||
|
},
|
||||||
|
"id": "bd8fe88e-7876-45dd-8344-983674fba79d",
|
||||||
|
"name": "Format Clear",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "74860e7f-42e3-448f-9e46-5cfb6f12cdc2",
|
||||||
|
"name": "Push to Gotify (Reminder)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Reminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "b1dbd39b-87c3-43ec-9cec-f63eabbd0fa5",
|
||||||
|
"name": "Push to Gotify (Clear)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Reminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every Monday 9 AM": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Inbox Docs",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Inbox Docs": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has Inbox Items?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has Inbox Items?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Reminder",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Clear",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Reminder": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify (Reminder)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Clear": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify (Clear)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "reminders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheduled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "2b80579b-8d89-42d5-8b8b-9137f59ea206"
|
||||||
|
}
|
||||||
383
n8n-workflows/15-n8n-health-monitor-v1.0.json
Normal file
383
n8n-workflows/15-n8n-health-monitor-v1.0.json
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
{
|
||||||
|
"name": "n8n Self-Health Monitor v1.0",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "minutes",
|
||||||
|
"minutesInterval": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "dabcb0ae-d513-410e-b779-4570df5c4a35",
|
||||||
|
"name": "Every 15 Minutes",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "http://10.5.1.6:5678/api/v1/executions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "X-N8N-API-KEY",
|
||||||
|
"value": "={{ $env.N8N_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"value": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "includeData",
|
||||||
|
"value": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"value": "50"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "806d75e9-b95a-499a-b997-a7f7dc046775",
|
||||||
|
"name": "Fetch Failed Executions",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst executions = data.data || [];\nconst oneHourAgo = Date.now() - (60 * 60 * 1000);\n\n// Filter to failures in the past hour\nconst recent = executions.filter(e => {\n const ts = new Date(e.startedAt || e.stoppedAt || 0).getTime();\n return ts >= oneHourAgo;\n});\n\n// Group by workflow name + error message for dedup key\nconst failures = recent.map(e => ({\n executionId: e.id,\n workflowId: e.workflowId,\n workflowName: e.workflowData?.name || `Workflow ${e.workflowId}`,\n startedAt: e.startedAt,\n stoppedAt: e.stoppedAt,\n errorMessage: e.data?.resultData?.error?.message || 'Unknown error'\n}));\n\n// Deduplicate by workflowName (report once per workflow per check)\nconst seen = new Set();\nconst unique = failures.filter(f => {\n if (seen.has(f.workflowName)) return false;\n seen.add(f.workflowName);\n return true;\n});\n\nreturn [{ json: { hasFailures: unique.length > 0, count: unique.length, failures: unique } }];"
|
||||||
|
},
|
||||||
|
"id": "f5d47910-5047-4c4f-aa84-a6907aa38551",
|
||||||
|
"name": "Filter Recent Failures",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "a76d10a7-1313-467e-9648-471b296229a3",
|
||||||
|
"leftValue": "={{ $json.hasFailures }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "equal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "7c298433-ab89-435e-8955-73da6b986f46",
|
||||||
|
"name": "Has Failures?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\n// Fingerprint = sorted workflow names joined \u2014 stable across retriggers\nconst fingerprint = (d.failures || [])\n .map(f => f.workflowName)\n .sort()\n .join('|') || 'unknown';\nreturn [{ json: { ...d, fingerprint } }];"
|
||||||
|
},
|
||||||
|
"id": "d3609d45-ba9f-41d3-a9bd-8c558a719f10",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT fingerprint, last_seen FROM n8n_health_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '1 hour' LIMIT 1;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "15a3dd71-bc5b-4396-9534-63518e097438",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadyNotified = rows.length > 0;\nreturn [{ json: { ...alertData, alreadyNotified, shouldNotify: !alreadyNotified } }];"
|
||||||
|
},
|
||||||
|
"id": "a92590e3-7044-4ba3-9b39-f6a9457c5fbe",
|
||||||
|
"name": "Check Already Notified",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "b1e77af5-6875-47dd-97af-8e13958a7217",
|
||||||
|
"leftValue": "={{ $json.shouldNotify }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "equal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "39ac0eaf-73ce-4fe1-9c4b-d09ac920291d",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO n8n_health_dedup (fingerprint, last_seen, workflow_names) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ $json.failures.map(f => f.workflowName).join(\", \") }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW(), workflow_names = EXCLUDED.workflow_names;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "0c3be155-0c3a-4354-b863-73a3f5bbf598",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $('Build Fingerprint').first().json;\nconst lines = (d.failures || []).map(f =>\n ` \u2022 ${f.workflowName}\\n ${f.errorMessage.substring(0, 120)}`\n);\nconst body = `${d.count} workflow${d.count !== 1 ? 's' : ''} failed in the past hour:\\n\\n${lines.join('\\n')}\\n\\nhttps://n8n.paccoco.com`;\nreturn [{ json: { title: `\u26a0\ufe0f n8n: ${d.count} Workflow Failure${d.count !== 1 ? 's' : ''}`, message: body, priority: 7 } }];"
|
||||||
|
},
|
||||||
|
"id": "1c6b42bf-a38e-47b6-82ac-50c43ee9e583",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "09435c2f-9044-432a-8ca8-dd3ce3262c76",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2240,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "n8n Health"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every 15 Minutes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Failed Executions",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Failed Executions": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Filter Recent Failures",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Filter Recent Failures": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has Failures?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has Failures?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Fingerprint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Lookup Dedup",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check Already Notified",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check Already Notified": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Should Notify?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert Dedup Record",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "monitoring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "n8n"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
384
n8n-workflows/15-n8n-health-monitor-v1.1.json
Normal file
384
n8n-workflows/15-n8n-health-monitor-v1.1.json
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
{
|
||||||
|
"name": "n8n Self-Health Monitor v1.1",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "minutes",
|
||||||
|
"minutesInterval": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "dabcb0ae-d513-410e-b779-4570df5c4a35",
|
||||||
|
"name": "Every 15 Minutes",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "http://10.5.1.6:5678/api/v1/executions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "X-N8N-API-KEY",
|
||||||
|
"value": "={{ $env.N8N_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"value": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "includeData",
|
||||||
|
"value": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"value": "50"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "806d75e9-b95a-499a-b997-a7f7dc046775",
|
||||||
|
"name": "Fetch Failed Executions",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst executions = data.data || [];\nconst oneHourAgo = Date.now() - (60 * 60 * 1000);\n\n// Filter to failures in the past hour\nconst recent = executions.filter(e => {\n const ts = new Date(e.startedAt || e.stoppedAt || 0).getTime();\n return ts >= oneHourAgo;\n});\n\n// Group by workflow name + error message for dedup key\nconst failures = recent.map(e => ({\n executionId: e.id,\n workflowId: e.workflowId,\n workflowName: e.workflowData?.name || `Workflow ${e.workflowId}`,\n startedAt: e.startedAt,\n stoppedAt: e.stoppedAt,\n errorMessage: e.data?.resultData?.error?.message || 'Unknown error'\n}));\n\n// Deduplicate by workflowName (report once per workflow per check)\nconst seen = new Set();\nconst unique = failures.filter(f => {\n if (seen.has(f.workflowName)) return false;\n seen.add(f.workflowName);\n return true;\n});\n\nreturn [{ json: { hasFailures: unique.length > 0, hasFailuresStr: unique.length > 0 ? 'yes' : 'no', count: unique.length, failures: unique } }];"
|
||||||
|
},
|
||||||
|
"id": "f5d47910-5047-4c4f-aa84-a6907aa38551",
|
||||||
|
"name": "Filter Recent Failures",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "02ff82df-a31d-491b-9cd1-96874c8ae2fe",
|
||||||
|
"leftValue": "={{ $json.hasFailuresStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "7c298433-ab89-435e-8955-73da6b986f46",
|
||||||
|
"name": "Has Failures?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\n// Fingerprint = sorted workflow names joined \u2014 stable across retriggers\nconst fingerprint = (d.failures || [])\n .map(f => f.workflowName)\n .sort()\n .join('|') || 'unknown';\nreturn [{ json: { ...d, fingerprint } }];"
|
||||||
|
},
|
||||||
|
"id": "d3609d45-ba9f-41d3-a9bd-8c558a719f10",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT fingerprint, last_seen FROM n8n_health_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '1 hour' LIMIT 1;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "15a3dd71-bc5b-4396-9534-63518e097438",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadyNotified = rows.length > 0;\nreturn [{ json: { ...alertData, alreadyNotified, shouldNotify: !alreadyNotified, shouldNotifyStr: !alreadyNotified ? 'yes' : 'no' } }];"
|
||||||
|
},
|
||||||
|
"id": "a92590e3-7044-4ba3-9b39-f6a9457c5fbe",
|
||||||
|
"name": "Check Already Notified",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "aa5db0c3-75a7-4ea4-b29e-c12568ccf320",
|
||||||
|
"leftValue": "={{ $json.shouldNotifyStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "39ac0eaf-73ce-4fe1-9c4b-d09ac920291d",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO n8n_health_dedup (fingerprint, last_seen, workflow_names) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ $json.failures.map(f => f.workflowName).join(\", \") }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW(), workflow_names = EXCLUDED.workflow_names;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "0c3be155-0c3a-4354-b863-73a3f5bbf598",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $('Build Fingerprint').first().json;\nconst lines = (d.failures || []).map(f =>\n ` \u2022 ${f.workflowName}\\n ${f.errorMessage.substring(0, 120)}`\n);\nconst body = `${d.count} workflow${d.count !== 1 ? 's' : ''} failed in the past hour:\\n\\n${lines.join('\\n')}\\n\\nhttps://n8n.paccoco.com`;\nreturn [{ json: { title: `\u26a0\ufe0f n8n: ${d.count} Workflow Failure${d.count !== 1 ? 's' : ''}`, message: body, priority: 7 } }];"
|
||||||
|
},
|
||||||
|
"id": "1c6b42bf-a38e-47b6-82ac-50c43ee9e583",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "09435c2f-9044-432a-8ca8-dd3ce3262c76",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2240,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "n8n Health"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every 15 Minutes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Failed Executions",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Failed Executions": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Filter Recent Failures",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Filter Recent Failures": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has Failures?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has Failures?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Fingerprint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Lookup Dedup",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check Already Notified",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check Already Notified": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Should Notify?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upsert Dedup Record",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Push to Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "monitoring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "n8n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "febfda9b-0f4e-40b5-ab84-82ba9b7fcf29"
|
||||||
|
}
|
||||||
302
n8n-workflows/15-n8n-health-monitor-v1.2.json
Normal file
302
n8n-workflows/15-n8n-health-monitor-v1.2.json
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
{
|
||||||
|
"name": "n8n Self-Health Monitor v1.2",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "minutes",
|
||||||
|
"minutesInterval": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "dabcb0ae-d513-410e-b779-4570df5c4a35",
|
||||||
|
"name": "Every 15 Minutes",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "http://10.5.1.6:5678/api/v1/workflows",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "X-N8N-API-KEY",
|
||||||
|
"value": "={{ $env.N8N_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"value": "250"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"name": "Fetch All Workflows",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [224, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "http://10.5.1.6:5678/api/v1/executions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "X-N8N-API-KEY",
|
||||||
|
"value": "={{ $env.N8N_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"value": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "includeData",
|
||||||
|
"value": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"value": "50"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "806d75e9-b95a-499a-b997-a7f7dc046775",
|
||||||
|
"name": "Fetch Failed Executions",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [448, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst executions = data.data || [];\nconst oneHourAgo = Date.now() - (60 * 60 * 1000);\n\n// Build workflow ID → name lookup from previously fetched workflows\nconst wfResponse = $('Fetch All Workflows').first().json;\nconst nameMap = {};\n(wfResponse.data || []).forEach(wf => { nameMap[wf.id] = wf.name; });\n\n// Filter to failures in the past hour\nconst recent = executions.filter(e => {\n const ts = new Date(e.startedAt || e.stoppedAt || 0).getTime();\n return ts >= oneHourAgo;\n});\n\n// Map executions to failure objects, resolving names from the lookup\nconst failures = recent.map(e => ({\n executionId: e.id,\n workflowId: e.workflowId,\n workflowName: nameMap[e.workflowId] || e.workflowData?.name || `Workflow ${e.workflowId}`,\n startedAt: e.startedAt,\n stoppedAt: e.stoppedAt,\n errorMessage: e.data?.resultData?.error?.message || 'Unknown error'\n}));\n\n// Deduplicate by workflowName (report once per workflow per check)\nconst seen = new Set();\nconst unique = failures.filter(f => {\n if (seen.has(f.workflowName)) return false;\n seen.add(f.workflowName);\n return true;\n});\n\nreturn [{ json: { hasFailures: unique.length > 0, hasFailuresStr: unique.length > 0 ? 'yes' : 'no', count: unique.length, failures: unique } }];"
|
||||||
|
},
|
||||||
|
"id": "f5d47910-5047-4c4f-aa84-a6907aa38551",
|
||||||
|
"name": "Filter Recent Failures",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [672, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "02ff82df-a31d-491b-9cd1-96874c8ae2fe",
|
||||||
|
"leftValue": "={{ $json.hasFailuresStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "7c298433-ab89-435e-8955-73da6b986f46",
|
||||||
|
"name": "Has Failures?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [896, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\n// Fingerprint = sorted workflow names joined — stable across retriggers\nconst fingerprint = (d.failures || [])\n .map(f => f.workflowName)\n .sort()\n .join('|') || 'unknown';\nreturn [{ json: { ...d, fingerprint } }];"
|
||||||
|
},
|
||||||
|
"id": "d3609d45-ba9f-41d3-a9bd-8c558a719f10",
|
||||||
|
"name": "Build Fingerprint",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1120, -120]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT fingerprint, last_seen FROM n8n_health_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '1 hour' LIMIT 1;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "15a3dd71-bc5b-4396-9534-63518e097438",
|
||||||
|
"name": "Lookup Dedup",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [1344, -120],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadyNotified = rows.length > 0;\nreturn [{ json: { ...alertData, alreadyNotified, shouldNotify: !alreadyNotified, shouldNotifyStr: !alreadyNotified ? 'yes' : 'no' } }];"
|
||||||
|
},
|
||||||
|
"id": "a92590e3-7044-4ba3-9b39-f6a9457c5fbe",
|
||||||
|
"name": "Check Already Notified",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1568, -120]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "aa5db0c3-75a7-4ea4-b29e-c12568ccf320",
|
||||||
|
"leftValue": "={{ $json.shouldNotifyStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "39ac0eaf-73ce-4fe1-9c4b-d09ac920291d",
|
||||||
|
"name": "Should Notify?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [1792, -120]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO n8n_health_dedup (fingerprint, last_seen, workflow_names) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ $json.failures.map(f => f.workflowName).join(\", \") }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW(), workflow_names = EXCLUDED.workflow_names;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "0c3be155-0c3a-4354-b863-73a3f5bbf598",
|
||||||
|
"name": "Upsert Dedup Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [2016, -120],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "n9svoXemqSZoNNUB",
|
||||||
|
"name": "Postgres account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $('Build Fingerprint').first().json;\nconst lines = (d.failures || []).map(f =>\n ` • ${f.workflowName}\\n ${f.errorMessage.substring(0, 120)}`\n);\nconst body = `${d.count} workflow${d.count !== 1 ? 's' : ''} failed in the past hour:\\n\\n${lines.join('\\n')}\\n\\nhttps://n8n.paccoco.com`;\nreturn [{ json: { title: `⚠️ n8n: ${d.count} Workflow Failure${d.count !== 1 ? 's' : ''}`, message: body, priority: 7 } }];"
|
||||||
|
},
|
||||||
|
"id": "1c6b42bf-a38e-47b6-82ac-50c43ee9e583",
|
||||||
|
"name": "Format Notification",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [2240, -120]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "09435c2f-9044-432a-8ca8-dd3ce3262c76",
|
||||||
|
"name": "Push to Gotify",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [2464, -120],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "n8n Health"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every 15 Minutes": {
|
||||||
|
"main": [[{ "node": "Fetch All Workflows", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Fetch All Workflows": {
|
||||||
|
"main": [[{ "node": "Fetch Failed Executions", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Fetch Failed Executions": {
|
||||||
|
"main": [[{ "node": "Filter Recent Failures", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Filter Recent Failures": {
|
||||||
|
"main": [[{ "node": "Has Failures?", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Has Failures?": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Build Fingerprint", "type": "main", "index": 0 }],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Fingerprint": {
|
||||||
|
"main": [[{ "node": "Lookup Dedup", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Lookup Dedup": {
|
||||||
|
"main": [[{ "node": "Check Already Notified", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Check Already Notified": {
|
||||||
|
"main": [[{ "node": "Should Notify?", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Should Notify?": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Upsert Dedup Record", "type": "main", "index": 0 }],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upsert Dedup Record": {
|
||||||
|
"main": [[{ "node": "Format Notification", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Notification": {
|
||||||
|
"main": [[{ "node": "Push to Gotify", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{ "name": "monitoring" },
|
||||||
|
{ "name": "scheduled" },
|
||||||
|
{ "name": "n8n" }
|
||||||
|
],
|
||||||
|
"id": "febfda9b-0f4e-40b5-ab84-82ba9b7fcf29"
|
||||||
|
}
|
||||||
449
n8n-workflows/16-nfs-watchdog-v1.0.json
Normal file
449
n8n-workflows/16-nfs-watchdog-v1.0.json
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
{
|
||||||
|
"name": "NFS Mount Watchdog + Auto-Heal v1.0",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "minutes",
|
||||||
|
"minutesInterval": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "8c466a67-a0fa-42dc-8ed8-0918834fc3a7",
|
||||||
|
"name": "Every 5 Minutes",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "mountpoint -q /mnt/unraid/data/media && echo MEDIA_OK || echo MEDIA_MISSING; mountpoint -q /mnt/unraid/data/photos && echo PHOTOS_OK || echo PHOTOS_MISSING"
|
||||||
|
},
|
||||||
|
"id": "f5b2fee9-d67a-4da7-80dd-f72e6078aed9",
|
||||||
|
"name": "Probe Mounts",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst mediaOk = out.includes('MEDIA_OK');\nconst photosOk = out.includes('PHOTOS_OK');\nconst missing = [];\nif (!mediaOk) missing.push({ mount: '/mnt/unraid/data/media', label: 'media',\n containers: ['plex','audiobookshelf'] });\nif (!photosOk) missing.push({ mount: '/mnt/unraid/data/photos', label: 'photos',\n containers: ['immich-server','immich-microservices','immich-machine-learning'] });\nreturn [{ json: { mediaOk, photosOk, anyMissing: missing.length > 0, missing } }];\n"
|
||||||
|
},
|
||||||
|
"id": "25bb3239-49cb-4570-b706-4022b9aa751c",
|
||||||
|
"name": "Parse Mount Status",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "442964fa-a29c-4aa2-98d1-9e25e927c496",
|
||||||
|
"leftValue": "={{ $json.anyMissing }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "equal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "68b74832-85a7-4193-9382-a0c22a666e66",
|
||||||
|
"name": "Any Missing?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh 2>&1"
|
||||||
|
},
|
||||||
|
"id": "b18c7537-0c2f-4122-bfe5-6469d6be6853",
|
||||||
|
"name": "Run Mount Script",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"resume": "timeInterval",
|
||||||
|
"unit": "seconds",
|
||||||
|
"value": 10
|
||||||
|
},
|
||||||
|
"id": "2e927f83-9ddc-4816-8ee6-76d21ce21a5e",
|
||||||
|
"name": "Wait 10s",
|
||||||
|
"type": "n8n-nodes-base.wait",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"webhookId": "4e89e8d0-7f08-4378-a2b7-63756665b039"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "mountpoint -q /mnt/unraid/data/media && echo MEDIA_OK || echo MEDIA_MISSING; mountpoint -q /mnt/unraid/data/photos && echo PHOTOS_OK || echo PHOTOS_MISSING"
|
||||||
|
},
|
||||||
|
"id": "d15c0c90-e94f-404a-a787-75f1b1b5befa",
|
||||||
|
"name": "Re-probe Mounts",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst mediaOk = out.includes('MEDIA_OK');\nconst photosOk = out.includes('PHOTOS_OK');\n\n// What was originally missing (from step 3)\nconst originalMissing = $('Parse Mount Status').first().json.missing || [];\n\n// Which of the originally-missing mounts are now up?\nconst healed = originalMissing.filter(m =>\n (m.label === 'media' && mediaOk) ||\n (m.label === 'photos' && photosOk)\n);\nconst stillDown = originalMissing.filter(m =>\n (m.label === 'media' && !mediaOk) ||\n (m.label === 'photos' && !photosOk)\n);\n\n// Build flat container list to restart (only for healed mounts)\nconst containersToRestart = [...new Set(healed.flatMap(m => m.containers))];\nconst restartCmd = containersToRestart.length > 0\n ? `sudo docker restart ${containersToRestart.join(' ')}`\n : 'echo NO_RESTART_NEEDED';\n\nreturn [{ json: {\n allHealed: stillDown.length === 0,\n healed, stillDown,\n containersToRestart,\n restartCmd,\n originalMissing\n}}];\n"
|
||||||
|
},
|
||||||
|
"id": "48de38c2-df08-406f-ad61-809ca6e306a1",
|
||||||
|
"name": "Parse Heal Result",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "566dda6e-2234-406a-ad87-c886c7c5ae08",
|
||||||
|
"leftValue": "={{ $json.allHealed }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "equal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29a1b544-97b3-433f-9821-2bf5a8af95d2",
|
||||||
|
"name": "Healed?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "={{ $json.restartCmd }}"
|
||||||
|
},
|
||||||
|
"id": "8d791509-9e18-44c1-bf58-ed3027927710",
|
||||||
|
"name": "Restart Containers",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
-240
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst d = $('Parse Heal Result').first().json;\nconst mountList = d.healed.map(m => ` \u2022 ${m.mount}`).join('\\n');\nconst containers = d.containersToRestart.join(', ') || 'none';\nconst body = `NFS mounts were missing and have been automatically remounted.\\n\\nRemounted:\\n${mountList}\\n\\nContainers restarted: ${containers}`;\nreturn [{ json: { title: '\ud83d\udd17 NFS Auto-Healed', message: body, priority: 5 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "75c8976f-d622-43cd-a332-72c32f0c7c60",
|
||||||
|
"name": "Format Healed Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2240,
|
||||||
|
-240
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "38705cd2-775b-4ef9-b470-4a7709f132e5",
|
||||||
|
"name": "Gotify (Healed)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2464,
|
||||||
|
-240
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Uptime / Infra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst d = $('Parse Heal Result').first().json;\nconst downList = d.stillDown.map(m => ` \u2022 ${m.mount}`).join('\\n');\nconst healedList = d.healed.length > 0\n ? `\\nPartially healed:\\n${d.healed.map(m => ' \u2022 ' + m.mount).join('\\n')}`\n : '';\nconst body = `NFS mount script ran but the following paths are still unavailable. Manual intervention required.\\n\\nStill down:\\n${downList}${healedList}\\n\\nCheck Serenity (TrueNAS) is up and NFS service is running.`;\nreturn [{ json: { title: '\ud83d\udea8 NFS Down \u2014 Manual Fix Needed', message: body, priority: 9 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "074b984f-7fc2-45bc-b97f-f5db4d8d589b",
|
||||||
|
"name": "Format Escalation",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
-20
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "f929dd77-6072-4ac9-9dc4-11246110efed",
|
||||||
|
"name": "Gotify (Escalation)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2240,
|
||||||
|
-20
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Uptime / Infra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every 5 Minutes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Probe Mounts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Probe Mounts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Mount Status",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Mount Status": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Any Missing?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Any Missing?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Run Mount Script",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Run Mount Script": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Wait 10s",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Wait 10s": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Re-probe Mounts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Re-probe Mounts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Heal Result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Heal Result": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Healed?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Healed?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Restart Containers",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Escalation",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Restart Containers": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Healed Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Healed Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Gotify (Healed)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Escalation": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Gotify (Escalation)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "monitoring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "infrastructure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheduled"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
450
n8n-workflows/16-nfs-watchdog-v1.1.json
Normal file
450
n8n-workflows/16-nfs-watchdog-v1.1.json
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
{
|
||||||
|
"name": "NFS Mount Watchdog + Auto-Heal v1.1",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "minutes",
|
||||||
|
"minutesInterval": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "8c466a67-a0fa-42dc-8ed8-0918834fc3a7",
|
||||||
|
"name": "Every 5 Minutes",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "mountpoint -q /mnt/unraid/data/media && echo MEDIA_OK || echo MEDIA_MISSING; mountpoint -q /mnt/unraid/data/photos && echo PHOTOS_OK || echo PHOTOS_MISSING"
|
||||||
|
},
|
||||||
|
"id": "f5b2fee9-d67a-4da7-80dd-f72e6078aed9",
|
||||||
|
"name": "Probe Mounts",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst mediaOk = out.includes('MEDIA_OK');\nconst photosOk = out.includes('PHOTOS_OK');\nconst missing = [];\nif (!mediaOk) missing.push({ mount: '/mnt/unraid/data/media', label: 'media',\n containers: ['plex','audiobookshelf'] });\nif (!photosOk) missing.push({ mount: '/mnt/unraid/data/photos', label: 'photos',\n containers: ['immich-server','immich-microservices','immich-machine-learning'] });\nreturn [{ json: { mediaOk, photosOk, anyMissing: missing.length > 0, anyMissingStr: missing.length > 0 ? 'yes' : 'no', missing } }];\n"
|
||||||
|
},
|
||||||
|
"id": "25bb3239-49cb-4570-b706-4022b9aa751c",
|
||||||
|
"name": "Parse Mount Status",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "70384f84-9d15-4e45-b295-67202a39a227",
|
||||||
|
"leftValue": "={{ $json.anyMissingStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "68b74832-85a7-4193-9382-a0c22a666e66",
|
||||||
|
"name": "Any Missing?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
672,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh 2>&1"
|
||||||
|
},
|
||||||
|
"id": "b18c7537-0c2f-4122-bfe5-6469d6be6853",
|
||||||
|
"name": "Run Mount Script",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
896,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"resume": "timeInterval",
|
||||||
|
"unit": "seconds",
|
||||||
|
"value": 10
|
||||||
|
},
|
||||||
|
"id": "2e927f83-9ddc-4816-8ee6-76d21ce21a5e",
|
||||||
|
"name": "Wait 10s",
|
||||||
|
"type": "n8n-nodes-base.wait",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"webhookId": "4e89e8d0-7f08-4378-a2b7-63756665b039"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "mountpoint -q /mnt/unraid/data/media && echo MEDIA_OK || echo MEDIA_MISSING; mountpoint -q /mnt/unraid/data/photos && echo PHOTOS_OK || echo PHOTOS_MISSING"
|
||||||
|
},
|
||||||
|
"id": "d15c0c90-e94f-404a-a787-75f1b1b5befa",
|
||||||
|
"name": "Re-probe Mounts",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1344,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst mediaOk = out.includes('MEDIA_OK');\nconst photosOk = out.includes('PHOTOS_OK');\n\n// What was originally missing (from step 3)\nconst originalMissing = $('Parse Mount Status').first().json.missing || [];\n\n// Which of the originally-missing mounts are now up?\nconst healed = originalMissing.filter(m =>\n (m.label === 'media' && mediaOk) ||\n (m.label === 'photos' && photosOk)\n);\nconst stillDown = originalMissing.filter(m =>\n (m.label === 'media' && !mediaOk) ||\n (m.label === 'photos' && !photosOk)\n);\n\n// Build flat container list to restart (only for healed mounts)\nconst containersToRestart = [...new Set(healed.flatMap(m => m.containers))];\nconst restartCmd = containersToRestart.length > 0\n ? `sudo docker restart ${containersToRestart.join(' ')}`\n : 'echo NO_RESTART_NEEDED';\n\nreturn [{ json: {\n allHealed: stillDown.length === 0, allHealedStr: stillDown.length === 0 ? 'yes' : 'no',\n healed, stillDown,\n containersToRestart,\n restartCmd,\n originalMissing\n}}];\n"
|
||||||
|
},
|
||||||
|
"id": "48de38c2-df08-406f-ad61-809ca6e306a1",
|
||||||
|
"name": "Parse Heal Result",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1568,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "32b2dfd5-9958-45d4-94fc-410ce4d9543d",
|
||||||
|
"leftValue": "={{ $json.allHealedStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29a1b544-97b3-433f-9821-2bf5a8af95d2",
|
||||||
|
"name": "Healed?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1792,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "={{ $json.restartCmd }}"
|
||||||
|
},
|
||||||
|
"id": "8d791509-9e18-44c1-bf58-ed3027927710",
|
||||||
|
"name": "Restart Containers",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
-240
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst d = $('Parse Heal Result').first().json;\nconst mountList = d.healed.map(m => ` \u2022 ${m.mount}`).join('\\n');\nconst containers = d.containersToRestart.join(', ') || 'none';\nconst body = `NFS mounts were missing and have been automatically remounted.\\n\\nRemounted:\\n${mountList}\\n\\nContainers restarted: ${containers}`;\nreturn [{ json: { title: '\ud83d\udd17 NFS Auto-Healed', message: body, priority: 5 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "75c8976f-d622-43cd-a332-72c32f0c7c60",
|
||||||
|
"name": "Format Healed Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2240,
|
||||||
|
-240
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "38705cd2-775b-4ef9-b470-4a7709f132e5",
|
||||||
|
"name": "Gotify (Healed)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2464,
|
||||||
|
-240
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Uptime / Infra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst d = $('Parse Heal Result').first().json;\nconst downList = d.stillDown.map(m => ` \u2022 ${m.mount}`).join('\\n');\nconst healedList = d.healed.length > 0\n ? `\\nPartially healed:\\n${d.healed.map(m => ' \u2022 ' + m.mount).join('\\n')}`\n : '';\nconst body = `NFS mount script ran but the following paths are still unavailable. Manual intervention required.\\n\\nStill down:\\n${downList}${healedList}\\n\\nCheck Serenity (TrueNAS) is up and NFS service is running.`;\nreturn [{ json: { title: '\ud83d\udea8 NFS Down \u2014 Manual Fix Needed', message: body, priority: 9 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "074b984f-7fc2-45bc-b97f-f5db4d8d589b",
|
||||||
|
"name": "Format Escalation",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2016,
|
||||||
|
-20
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "f929dd77-6072-4ac9-9dc4-11246110efed",
|
||||||
|
"name": "Gotify (Escalation)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
2240,
|
||||||
|
-20
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Uptime / Infra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every 5 Minutes": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Probe Mounts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Probe Mounts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Mount Status",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Mount Status": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Any Missing?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Any Missing?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Run Mount Script",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Run Mount Script": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Wait 10s",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Wait 10s": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Re-probe Mounts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Re-probe Mounts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Heal Result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Heal Result": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Healed?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Healed?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Restart Containers",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Escalation",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Restart Containers": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format Healed Notify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Healed Notify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Gotify (Healed)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format Escalation": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Gotify (Escalation)",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "monitoring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "infrastructure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheduled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "8587d430-3d53-4559-b610-214e12164c14"
|
||||||
|
}
|
||||||
303
n8n-workflows/16-nfs-watchdog-v1.2.json
Normal file
303
n8n-workflows/16-nfs-watchdog-v1.2.json
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
{
|
||||||
|
"name": "NFS Mount Watchdog + Auto-Heal v1.2",
|
||||||
|
"active": true,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "minutes",
|
||||||
|
"minutesInterval": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "8c466a67-a0fa-42dc-8ed8-0918834fc3a7",
|
||||||
|
"name": "Every 5 Minutes",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "mountpoint -q /mnt/unraid/data && echo DATA_OK || echo DATA_MISSING; mountpoint -q /mnt/unraid/immich && echo IMMICH_OK || echo IMMICH_MISSING"
|
||||||
|
},
|
||||||
|
"id": "f5b2fee9-d67a-4da7-80dd-f72e6078aed9",
|
||||||
|
"name": "Probe Mounts",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [224, 0],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst dataOk = out.includes('DATA_OK');\nconst immichOk = out.includes('IMMICH_OK');\nconst missing = [];\nif (!dataOk) missing.push({ mount: '/mnt/unraid/data', label: 'data',\n containers: ['ix-plex-plex-1', 'ix-audiobookshelf-audiobookshelf-1'] });\nif (!immichOk) missing.push({ mount: '/mnt/unraid/immich', label: 'immich',\n containers: ['ix-immich-immich-server-1', 'ix-immich-immich-machine-learning-1'] });\nreturn [{ json: { dataOk, immichOk, anyMissing: missing.length > 0, anyMissingStr: missing.length > 0 ? 'yes' : 'no', missing } }];\n"
|
||||||
|
},
|
||||||
|
"id": "25bb3239-49cb-4570-b706-4022b9aa751c",
|
||||||
|
"name": "Parse Mount Status",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [448, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "70384f84-9d15-4e45-b295-67202a39a227",
|
||||||
|
"leftValue": "={{ $json.anyMissingStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "68b74832-85a7-4193-9382-a0c22a666e66",
|
||||||
|
"name": "Any Missing?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [672, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "sudo /usr/bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh 2>&1"
|
||||||
|
},
|
||||||
|
"id": "b18c7537-0c2f-4122-bfe5-6469d6be6853",
|
||||||
|
"name": "Run Mount Script",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [896, -120],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"resume": "timeInterval",
|
||||||
|
"unit": "seconds",
|
||||||
|
"value": 10
|
||||||
|
},
|
||||||
|
"id": "2e927f83-9ddc-4816-8ee6-76d21ce21a5e",
|
||||||
|
"name": "Wait 10s",
|
||||||
|
"type": "n8n-nodes-base.wait",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [1120, -120],
|
||||||
|
"webhookId": "4e89e8d0-7f08-4378-a2b7-63756665b039"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "mountpoint -q /mnt/unraid/data && echo DATA_OK || echo DATA_MISSING; mountpoint -q /mnt/unraid/immich && echo IMMICH_OK || echo IMMICH_MISSING"
|
||||||
|
},
|
||||||
|
"id": "d15c0c90-e94f-404a-a787-75f1b1b5befa",
|
||||||
|
"name": "Re-probe Mounts",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [1344, -120],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst dataOk = out.includes('DATA_OK');\nconst immichOk = out.includes('IMMICH_OK');\n\nconst originalMissing = $('Parse Mount Status').first().json.missing || [];\n\nconst healed = originalMissing.filter(m =>\n (m.label === 'data' && dataOk) ||\n (m.label === 'immich' && immichOk)\n);\nconst stillDown = originalMissing.filter(m =>\n (m.label === 'data' && !dataOk) ||\n (m.label === 'immich' && !immichOk)\n);\n\nconst containersToRestart = [...new Set(healed.flatMap(m => m.containers))];\nconst restartCmd = containersToRestart.length > 0\n ? `sudo docker restart ${containersToRestart.join(' ')}`\n : 'echo NO_RESTART_NEEDED';\n\nreturn [{ json: {\n allHealed: stillDown.length === 0,\n allHealedStr: stillDown.length === 0 ? 'yes' : 'no',\n healed,\n stillDown,\n containersToRestart,\n restartCmd,\n originalMissing\n} }];\n"
|
||||||
|
},
|
||||||
|
"id": "48de38c2-df08-406f-ad61-809ca6e306a1",
|
||||||
|
"name": "Parse Heal Result",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1568, -120]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "32b2dfd5-9958-45d4-94fc-410ce4d9543d",
|
||||||
|
"leftValue": "={{ $json.allHealedStr }}",
|
||||||
|
"rightValue": "yes",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "29a1b544-97b3-433f-9821-2bf5a8af95d2",
|
||||||
|
"name": "Healed?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [1792, -120]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"command": "={{ $json.restartCmd }}"
|
||||||
|
},
|
||||||
|
"id": "8d791509-9e18-44c1-bf58-ed3027927710",
|
||||||
|
"name": "Restart Containers",
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [2016, -240],
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "PD SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $('Parse Heal Result').first().json;\nconst mountList = d.healed.map(m => ` • ${m.mount}`).join('\\n');\nconst containers = d.containersToRestart.join(', ') || 'none';\nconst body = `NFS mounts were missing and have been automatically remounted.\\n\\nRemounted:\\n${mountList}\\n\\nContainers restarted: ${containers}`;\nreturn [{ json: { title: '🔗 NFS Auto-Healed', message: body, priority: 5 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "75c8976f-d622-43cd-a332-72c32f0c7c60",
|
||||||
|
"name": "Format Healed Notify",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [2240, -240]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "38705cd2-775b-4ef9-b470-4a7709f132e5",
|
||||||
|
"name": "Gotify (Healed)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [2464, -240],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Uptime / Infra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $('Parse Heal Result').first().json;\nconst downList = d.stillDown.map(m => ` • ${m.mount}`).join('\\n');\nconst healedList = d.healed.length > 0\n ? `\\nPartially healed:\\n${d.healed.map(m => ' • ' + m.mount).join('\\n')}`\n : '';\nconst body = `NFS mount script ran but the following paths are still unavailable. Manual intervention required.\\n\\nStill down:\\n${downList}${healedList}\\n\\nCheck Serenity (10.5.1.5) is up and NFS service is running.`;\nreturn [{ json: { title: '🚨 NFS Down — Manual Fix Needed', message: body, priority: 9 } }];\n"
|
||||||
|
},
|
||||||
|
"id": "074b984f-7fc2-45bc-b97f-f5db4d8d589b",
|
||||||
|
"name": "Format Escalation",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [2016, -20]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"message": "={{ $json.message }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"priority": "={{ $json.priority }}",
|
||||||
|
"title": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "f929dd77-6072-4ac9-9dc4-11246110efed",
|
||||||
|
"name": "Gotify (Escalation)",
|
||||||
|
"type": "n8n-nodes-base.gotify",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [2240, -20],
|
||||||
|
"credentials": {
|
||||||
|
"gotifyApi": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Uptime / Infra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every 5 Minutes": {
|
||||||
|
"main": [[{ "node": "Probe Mounts", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Probe Mounts": {
|
||||||
|
"main": [[{ "node": "Parse Mount Status", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Parse Mount Status": {
|
||||||
|
"main": [[{ "node": "Any Missing?", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Any Missing?": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Run Mount Script", "type": "main", "index": 0 }],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Run Mount Script": {
|
||||||
|
"main": [[{ "node": "Wait 10s", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Wait 10s": {
|
||||||
|
"main": [[{ "node": "Re-probe Mounts", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Re-probe Mounts": {
|
||||||
|
"main": [[{ "node": "Parse Heal Result", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Parse Heal Result": {
|
||||||
|
"main": [[{ "node": "Healed?", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Healed?": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Restart Containers", "type": "main", "index": 0 }],
|
||||||
|
[{ "node": "Format Escalation", "type": "main", "index": 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Restart Containers": {
|
||||||
|
"main": [[{ "node": "Format Healed Notify", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Healed Notify": {
|
||||||
|
"main": [[{ "node": "Gotify (Healed)", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Escalation": {
|
||||||
|
"main": [[{ "node": "Gotify (Escalation)", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{ "name": "monitoring" },
|
||||||
|
{ "name": "infrastructure" },
|
||||||
|
{ "name": "scheduled" }
|
||||||
|
]
|
||||||
|
}
|
||||||
364
n8n-workflows/17-paperless-intake-triage.json
Normal file
364
n8n-workflows/17-paperless-intake-triage.json
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless Intake Triage \u2192 Doris Review Queue",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "paperless/intake-triage",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "paperless-webhook",
|
||||||
|
"name": "Paperless Intake Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-980,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"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",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-760,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + ($json.document_id ? ($json.document_id + '/') : '?ordering=-added&page_size=1')}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fetch-document",
|
||||||
|
"name": "Fetch Paperless Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-540,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst response = $json;\nconst doc = response.results ? response.results[0] : response;\nif (!doc || !doc.id) throw new Error('No document found in Paperless API response');\nconst truncate = (s, n=9000) => String(s || '').replace(/\\s+/g, ' ').trim().slice(0, n);\nconst archiveUrl = `${($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '')}/documents/${doc.id}/details`;\nconst payload = {\n id: doc.id,\n title: doc.title || 'Untitled',\n original_filename: doc.original_filename || doc.original_file_name || doc.archive_filename || null,\n created: doc.created || null,\n added: doc.added || doc.created_date || null,\n correspondent: doc.correspondent_detail?.name || doc.correspondent_name || doc.correspondent || null,\n document_type: doc.document_type_detail?.name || doc.document_type_name || doc.document_type || null,\n tags: (doc.tags_detail || doc.tags || []).map(t => typeof t === 'string' ? t : (t.name || t.id)).filter(Boolean),\n archive_url: archiveUrl,\n content: truncate(doc.content || doc.text || '')\n};\nconst schema = `Return STRICT JSON only with exactly these keys: summary, suggested_title, document_type, suggested_correspondent, suggested_tags, due_date, needs_review, urgency, reason, confidence. document_type must be one of bill, receipt, school, medical, tax, insurance, legal, manual, letter, other. due_date is YYYY-MM-DD or null. urgency is none, low, medium, high. confidence is high, medium, low.`;\nreturn [{ json: { document: payload, llm_messages: [\n { role: 'system', content: 'You triage Paperless-NGX documents for a human review-first dashboard. Never invent due dates. Be conservative. ' + schema },\n { role: 'user', content: JSON.stringify(payload) }\n] } }];\n"
|
||||||
|
},
|
||||||
|
"id": "prepare-triage",
|
||||||
|
"name": "Prepare Compact Triage Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-320,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{($env.LITELLM_BASE_URL || 'http://10.5.1.6:4000').replace(/\\/$/, '') + '/v1/chat/completions'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ model: $env.PAPERLESS_TRIAGE_MODEL || 'medium', temperature: 0.1, response_format: { type: 'json_object' }, messages: $json.llm_messages }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 60000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "llm-triage",
|
||||||
|
"name": "LiteLLM Triage JSON",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-100,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nfunction parseTriage(input) {\n let content = input.choices?.[0]?.message?.content ?? input.text ?? input;\n if (typeof content !== 'string') content = JSON.stringify(content);\n content = content.trim().replace(/^```json\\s*/i, '').replace(/```$/,'').trim();\n return JSON.parse(content);\n}\nconst sourceDoc = $('Prepare Compact Triage Payload').first().json.document;\nconst t = parseTriage($json);\nconst allowedTypes = new Set(['bill','receipt','school','medical','tax','insurance','legal','manual','letter','other']);\nconst out = {\n summary: String(t.summary || '').slice(0, 800),\n suggested_title: String(t.suggested_title || sourceDoc.title || sourceDoc.original_filename || `Document ${sourceDoc.id}`).slice(0, 200),\n document_type: allowedTypes.has(t.document_type) ? t.document_type : 'other',\n suggested_correspondent: t.suggested_correspondent ? String(t.suggested_correspondent).slice(0, 160) : null,\n suggested_tags: Array.isArray(t.suggested_tags) ? [...new Set(t.suggested_tags.map(x => String(x).trim().toLowerCase()).filter(Boolean))].slice(0, 12) : [],\n due_date: /^\\d{4}-\\d{2}-\\d{2}$/.test(String(t.due_date || '')) ? String(t.due_date) : null,\n needs_review: Boolean(t.needs_review),\n urgency: ['none','low','medium','high'].includes(t.urgency) ? t.urgency : 'medium',\n reason: String(t.reason || 'No reason supplied; review required.').slice(0, 500),\n confidence: ['high','medium','low'].includes(t.confidence) ? t.confidence : 'low'\n};\nconst riskTypes = new Set(['legal','medical','school','tax','insurance']);\nconst billSignals = /\b(bill|payment|invoice|amount due|pay by|past due|statement)\b/i.test(`${out.summary} ${out.suggested_title} ${sourceDoc.content || ''}`);\nconst forcedReview = riskTypes.has(out.document_type) || Boolean(out.due_date) || billSignals || out.confidence === 'low' || ['medium','high'].includes(out.urgency);\nout.needs_review = out.needs_review || forcedReview;\nif (forcedReview && !/review/i.test(out.reason)) out.reason = `${out.reason} Safety gate requires human review.`;\nconst applySafe = String($env.PAPERLESS_TRIAGE_APPLY_SAFE || 'false').toLowerCase() === 'true';\nconst eligible_for_safe_apply = !out.needs_review && out.confidence === 'high' && ['none','low'].includes(out.urgency) && !riskTypes.has(out.document_type) && !out.due_date;\nconst reviewItem = {\n id: sourceDoc.id,\n title: sourceDoc.title,\n original_filename: sourceDoc.original_filename,\n added: sourceDoc.added,\n archive_url: sourceDoc.archive_url,\n current: { correspondent: sourceDoc.correspondent, document_type: sourceDoc.document_type, tags: sourceDoc.tags },\n triage: out,\n safety: { forced_review: forcedReview, eligible_for_safe_apply, apply_safe_enabled: applySafe },\n queued_at: new Date().toISOString()\n};\nreturn [{ json: { document: sourceDoc, triage: out, review_item: reviewItem, should_apply_safe: applySafe && eligible_for_safe_apply, should_notify: out.urgency === 'high' } }];\n"
|
||||||
|
},
|
||||||
|
"id": "parse-safety",
|
||||||
|
"name": "Parse JSON + Safety Gate",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
120,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"id": "write-queue",
|
||||||
|
"name": "Write Doris Review Queue JSON",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
340,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"boolean": [
|
||||||
|
{
|
||||||
|
"value1": "={{$json.should_apply_safe}}",
|
||||||
|
"value2": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "if-safe-apply",
|
||||||
|
"name": "Apply Safe Updates Enabled?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
560,
|
||||||
|
-80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document.id + '/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ title: $json.triage.suggested_title }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "safe-title-update",
|
||||||
|
"name": "Safe Title Update Only",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
780,
|
||||||
|
-160
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"boolean": [
|
||||||
|
{
|
||||||
|
"value1": "={{$json.should_notify && !!$env.GOTIFY_URL && !!$env.GOTIFY_TOKEN}}",
|
||||||
|
"value2": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "if-urgent",
|
||||||
|
"name": "Urgent Notification Configured?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
560,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{$env.GOTIFY_URL.replace(/\\/$/, '') + '/message'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "X-Gotify-Key",
|
||||||
|
"value": "={{$env.GOTIFY_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ title: 'Urgent Paperless document', message: `${$json.triage.suggested_title}\n${$json.triage.summary}\n${$json.document.archive_url}`, priority: 8 }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 15000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "gotify-urgent",
|
||||||
|
"name": "Send Urgent Gotify",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
780,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Paperless Intake Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Document ID",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Document ID": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Paperless Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Paperless Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Compact Triage Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Compact Triage Payload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "LiteLLM Triage JSON",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"LiteLLM Triage JSON": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse JSON + Safety Gate",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse JSON + Safety Gate": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Write Doris Review Queue JSON",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Write Doris Review Queue JSON": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Apply Safe Updates Enabled?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Apply Safe Updates Enabled?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Safe Title Update Only",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Urgent Notification Configured?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send Urgent Gotify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": false
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review-first"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
238
n8n-workflows/18-school-paperless-intake-v1.0.json
Normal file
238
n8n-workflows/18-school-paperless-intake-v1.0.json
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{
|
||||||
|
"name": "Telegram School Intake → Postgres → Paperless v1.0",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "school/intake/upload",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "school-intake-webhook",
|
||||||
|
"name": "School Intake Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-1180,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst crypto = require('crypto');\nconst req = $input.first().json;\nconst body = req.body || {};\nconst binary = $input.first().binary || {};\nconst upload = binary.data || Object.values(binary)[0];\nif (!upload) throw new Error('Expected multipart file field named \\\"data\\\".');\nconst required = ['class_name', 'assignment_name', 'submission_kind'];\nfor (const key of required) {\n if (!body[key] || !String(body[key]).trim()) {\n throw new Error('Missing required form field: ' + key + '. Required fields: class_name, assignment_name, submission_kind.');\n }\n}\nconst sanitize = (value, fallback = 'item') => String(value || fallback)\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 80) || fallback;\nconst originalName = upload.fileName || body.filename || 'upload.bin';\nconst extension = (() => {\n const m = String(originalName).match(/(\\.[A-Za-z0-9]{1,10})$/);\n return m ? m[1].toLowerCase() : '';\n})();\nconst fileBytes = Buffer.from(upload.data, 'base64');\nconst checksum = crypto.createHash('sha256').update(fileBytes).digest('hex');\nconst now = new Date();\nconst intakeId = [\n 'school',\n now.toISOString().slice(0,10).replace(/-/g,''),\n sanitize(body.class_name),\n sanitize(body.assignment_name),\n checksum.slice(0, 12)\n].join('-');\nconst storedFilename = intakeId + extension;\nconst title = [body.class_name, body.assignment_name, body.submission_kind].map(v => String(v).trim()).join(' — ');\nreturn [{\n json: {\n intake_id: intakeId,\n class_name: String(body.class_name).trim(),\n assignment_name: String(body.assignment_name).trim(),\n submission_kind: String(body.submission_kind).trim(),\n semester: body.semester ? String(body.semester).trim() : null,\n course_code: body.course_code ? String(body.course_code).trim() : null,\n paper_kind: body.paper_kind ? String(body.paper_kind).trim() : null,\n notes: body.notes ? String(body.notes).trim().slice(0, 4000) : null,\n source: body.source ? String(body.source).trim() : 'telegram',\n telegram_chat_id: body.telegram_chat_id ? String(body.telegram_chat_id).trim() : null,\n telegram_message_id: body.telegram_message_id ? String(body.telegram_message_id).trim() : null,\n original_filename: originalName,\n stored_filename: storedFilename,\n mime_type: upload.mimeType || 'application/octet-stream',\n file_size_bytes: fileBytes.length,\n checksum_sha256: checksum,\n paperless_title: title,\n received_at: now.toISOString()\n },\n binary: {\n data: {\n ...upload,\n fileName: storedFilename,\n mimeType: upload.mimeType || 'application/octet-stream'\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "normalize-request",
|
||||||
|
"name": "Normalize Intake Request",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO school_paperless_intake (\n intake_id,\n class_name,\n assignment_name,\n submission_kind,\n semester,\n course_code,\n paper_kind,\n notes,\n source,\n telegram_chat_id,\n telegram_message_id,\n original_filename,\n stored_filename,\n mime_type,\n file_size_bytes,\n checksum_sha256,\n status,\n received_at\n)\nVALUES (\n '{{ $json.intake_id }}',\n '{{ $json.class_name.replace(/'/g, \"''\") }}',\n '{{ $json.assignment_name.replace(/'/g, \"''\") }}',\n '{{ $json.submission_kind.replace(/'/g, \"''\") }}',\n {{ $json.semester ? \"'\" + $json.semester.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.course_code ? \"'\" + $json.course_code.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.paper_kind ? \"'\" + $json.paper_kind.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.notes ? \"'\" + $json.notes.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n '{{ $json.source.replace(/'/g, \"''\") }}',\n {{ $json.telegram_chat_id ? \"'\" + $json.telegram_chat_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.telegram_message_id ? \"'\" + $json.telegram_message_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n '{{ $json.original_filename.replace(/'/g, \"''\") }}',\n '{{ $json.stored_filename.replace(/'/g, \"''\") }}',\n '{{ $json.mime_type.replace(/'/g, \"''\") }}',\n {{ $json.file_size_bytes }},\n '{{ $json.checksum_sha256 }}',\n 'received',\n '{{ $json.received_at }}'::timestamptz\n)\nON CONFLICT (intake_id) DO UPDATE SET\n class_name = EXCLUDED.class_name,\n assignment_name = EXCLUDED.assignment_name,\n submission_kind = EXCLUDED.submission_kind,\n semester = EXCLUDED.semester,\n course_code = EXCLUDED.course_code,\n paper_kind = EXCLUDED.paper_kind,\n notes = EXCLUDED.notes,\n source = EXCLUDED.source,\n telegram_chat_id = EXCLUDED.telegram_chat_id,\n telegram_message_id = EXCLUDED.telegram_message_id,\n original_filename = EXCLUDED.original_filename,\n stored_filename = EXCLUDED.stored_filename,\n mime_type = EXCLUDED.mime_type,\n file_size_bytes = EXCLUDED.file_size_bytes,\n checksum_sha256 = EXCLUDED.checksum_sha256,\n status = 'received',\n received_at = EXCLUDED.received_at,\n updated_at = NOW();"
|
||||||
|
},
|
||||||
|
"id": "pg-insert-received",
|
||||||
|
"name": "Postgres Upsert Intake Received",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.6,
|
||||||
|
"position": [
|
||||||
|
-660,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Shared Postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/post_document/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "document",
|
||||||
|
"value": "data",
|
||||||
|
"parameterType": "formBinaryData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{$json.paperless_title}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "paperless-upload",
|
||||||
|
"name": "Upload to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-400,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst intake = $('Normalize Intake Request').first().json;\nconst response = $json;\nconst taskId = response.task_id || response.id || response.document?.id || null;\nlet paperlessDocumentId = response.document_id || response.document?.id || null;\nif (!paperlessDocumentId && typeof response === 'object' && response?.id && !response?.task_id) {\n paperlessDocumentId = response.id;\n}\nreturn [{ json: {\n ...intake,\n paperless_task_id: taskId ? String(taskId) : null,\n paperless_document_id: paperlessDocumentId ? String(paperlessDocumentId) : null,\n paperless_response: response,\n uploaded_at: new Date().toISOString()\n}}];"
|
||||||
|
},
|
||||||
|
"id": "capture-paperless-response",
|
||||||
|
"name": "Capture Paperless Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-140,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "UPDATE school_paperless_intake\nSET\n paperless_task_id = {{ $json.paperless_task_id ? \"'\" + $json.paperless_task_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n paperless_document_id = {{ $json.paperless_document_id ? \"'\" + $json.paperless_document_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n paperless_title = '{{ $json.paperless_title.replace(/'/g, \"''\") }}',\n status = 'uploaded',\n uploaded_at = '{{ $json.uploaded_at }}'::timestamptz,\n updated_at = NOW()\nWHERE intake_id = '{{ $json.intake_id }}';"
|
||||||
|
},
|
||||||
|
"id": "pg-mark-uploaded",
|
||||||
|
"name": "Postgres Mark Uploaded",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.6,
|
||||||
|
"position": [
|
||||||
|
120,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Shared Postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nreturn [{\n json: {\n ok: true,\n intake_id: $json.intake_id,\n status: 'uploaded',\n paperless_task_id: $json.paperless_task_id,\n paperless_document_id: $json.paperless_document_id,\n paperless_title: $json.paperless_title,\n stored_filename: $json.stored_filename,\n received_at: $json.received_at,\n uploaded_at: $json.uploaded_at\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "success-response",
|
||||||
|
"name": "Return Success Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
360,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"School Intake Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Normalize Intake Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Normalize Intake Request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Postgres Upsert Intake Received",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Postgres Upsert Intake Received": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upload to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upload to Paperless": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Capture Paperless Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Capture Paperless Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Postgres Mark Uploaded",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Postgres Mark Uploaded": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return Success Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": false,
|
||||||
|
"artifactVersion": "v1.0"
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "school"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "postgres"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "telegram"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
238
n8n-workflows/18-school-paperless-intake-v1.1.json
Normal file
238
n8n-workflows/18-school-paperless-intake-v1.1.json
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{
|
||||||
|
"name": "Telegram School Intake → Postgres → Paperless v1.1",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "school/intake/upload",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "school-intake-webhook",
|
||||||
|
"name": "School Intake Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-1180,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst req = $input.first().json;\nconst body = req.body || {};\nconst binary = $input.first().binary || {};\nconst upload = binary.data || Object.values(binary)[0];\nif (!upload) throw new Error('Expected multipart file upload in the webhook request.');\nconst required = ['class_name', 'assignment_name', 'submission_kind'];\nfor (const key of required) {\n if (!body[key] || !String(body[key]).trim()) {\n throw new Error('Missing required form field: ' + key + '. Required fields: class_name, assignment_name, submission_kind.');\n }\n}\nconst sanitize = (value, fallback = 'item') => String(value || fallback)\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 80) || fallback;\nconst stableHash = (str) => {\n let h1 = 0xdeadbeef;\n let h2 = 0x41c6ce57;\n for (let i = 0; i < str.length; i++) {\n const ch = str.charCodeAt(i);\n h1 = Math.imul(h1 ^ ch, 2654435761);\n h2 = Math.imul(h2 ^ ch, 1597334677);\n }\n h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);\n h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);\n return ((h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0'));\n};\nconst originalName = upload.fileName || body.filename || 'upload.bin';\nconst extension = (() => {\n const m = String(originalName).match(/(\\.[A-Za-z0-9]{1,10})$/);\n return m ? m[1].toLowerCase() : '';\n})();\nconst fileBytes = Buffer.from(upload.data, 'base64');\nconst checksum = stableHash([originalName, upload.mimeType || '', String(fileBytes.length), upload.data].join('|'));\nconst now = new Date();\nconst intakeId = [\n 'school',\n now.toISOString().slice(0,10).replace(/-/g,''),\n sanitize(body.class_name),\n sanitize(body.assignment_name),\n checksum.slice(0, 12)\n].join('-');\nconst storedFilename = intakeId + extension;\nconst title = [body.class_name, body.assignment_name, body.submission_kind].map(v => String(v).trim()).join(' — ');\nreturn [{\n json: {\n intake_id: intakeId,\n class_name: String(body.class_name).trim(),\n assignment_name: String(body.assignment_name).trim(),\n submission_kind: String(body.submission_kind).trim(),\n semester: body.semester ? String(body.semester).trim() : null,\n course_code: body.course_code ? String(body.course_code).trim() : null,\n paper_kind: body.paper_kind ? String(body.paper_kind).trim() : null,\n notes: body.notes ? String(body.notes).trim().slice(0, 4000) : null,\n source: body.source ? String(body.source).trim() : 'telegram',\n telegram_chat_id: body.telegram_chat_id ? String(body.telegram_chat_id).trim() : null,\n telegram_message_id: body.telegram_message_id ? String(body.telegram_message_id).trim() : null,\n original_filename: originalName,\n stored_filename: storedFilename,\n mime_type: upload.mimeType || 'application/octet-stream',\n file_size_bytes: fileBytes.length,\n checksum_sha256: checksum,\n paperless_title: title,\n received_at: now.toISOString()\n },\n binary: {\n data: {\n ...upload,\n fileName: storedFilename,\n mimeType: upload.mimeType || 'application/octet-stream'\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "normalize-request",
|
||||||
|
"name": "Normalize Intake Request",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO school_paperless_intake (\n intake_id,\n class_name,\n assignment_name,\n submission_kind,\n semester,\n course_code,\n paper_kind,\n notes,\n source,\n telegram_chat_id,\n telegram_message_id,\n original_filename,\n stored_filename,\n mime_type,\n file_size_bytes,\n checksum_sha256,\n status,\n received_at\n)\nVALUES (\n '{{ $json.intake_id }}',\n '{{ $json.class_name.replace(/'/g, \"''\") }}',\n '{{ $json.assignment_name.replace(/'/g, \"''\") }}',\n '{{ $json.submission_kind.replace(/'/g, \"''\") }}',\n {{ $json.semester ? \"'\" + $json.semester.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.course_code ? \"'\" + $json.course_code.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.paper_kind ? \"'\" + $json.paper_kind.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.notes ? \"'\" + $json.notes.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n '{{ $json.source.replace(/'/g, \"''\") }}',\n {{ $json.telegram_chat_id ? \"'\" + $json.telegram_chat_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n {{ $json.telegram_message_id ? \"'\" + $json.telegram_message_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n '{{ $json.original_filename.replace(/'/g, \"''\") }}',\n '{{ $json.stored_filename.replace(/'/g, \"''\") }}',\n '{{ $json.mime_type.replace(/'/g, \"''\") }}',\n {{ $json.file_size_bytes }},\n '{{ $json.checksum_sha256 }}',\n 'received',\n '{{ $json.received_at }}'::timestamptz\n)\nON CONFLICT (intake_id) DO UPDATE SET\n class_name = EXCLUDED.class_name,\n assignment_name = EXCLUDED.assignment_name,\n submission_kind = EXCLUDED.submission_kind,\n semester = EXCLUDED.semester,\n course_code = EXCLUDED.course_code,\n paper_kind = EXCLUDED.paper_kind,\n notes = EXCLUDED.notes,\n source = EXCLUDED.source,\n telegram_chat_id = EXCLUDED.telegram_chat_id,\n telegram_message_id = EXCLUDED.telegram_message_id,\n original_filename = EXCLUDED.original_filename,\n stored_filename = EXCLUDED.stored_filename,\n mime_type = EXCLUDED.mime_type,\n file_size_bytes = EXCLUDED.file_size_bytes,\n checksum_sha256 = EXCLUDED.checksum_sha256,\n status = 'received',\n received_at = EXCLUDED.received_at,\n updated_at = NOW();"
|
||||||
|
},
|
||||||
|
"id": "pg-insert-received",
|
||||||
|
"name": "Postgres Upsert Intake Received",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.6,
|
||||||
|
"position": [
|
||||||
|
-660,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Shared Postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/post_document/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "document",
|
||||||
|
"value": "data",
|
||||||
|
"parameterType": "formBinaryData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{$json.paperless_title}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "paperless-upload",
|
||||||
|
"name": "Upload to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-400,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst intake = $('Normalize Intake Request').first().json;\nconst response = $json;\nconst taskId = response.task_id || response.id || response.document?.id || null;\nlet paperlessDocumentId = response.document_id || response.document?.id || null;\nif (!paperlessDocumentId && typeof response === 'object' && response?.id && !response?.task_id) {\n paperlessDocumentId = response.id;\n}\nreturn [{ json: {\n ...intake,\n paperless_task_id: taskId ? String(taskId) : null,\n paperless_document_id: paperlessDocumentId ? String(paperlessDocumentId) : null,\n paperless_response: response,\n uploaded_at: new Date().toISOString()\n}}];"
|
||||||
|
},
|
||||||
|
"id": "capture-paperless-response",
|
||||||
|
"name": "Capture Paperless Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-140,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "UPDATE school_paperless_intake\nSET\n paperless_task_id = {{ $json.paperless_task_id ? \"'\" + $json.paperless_task_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n paperless_document_id = {{ $json.paperless_document_id ? \"'\" + $json.paperless_document_id.replace(/'/g, \"''\") + \"'\" : 'NULL' }},\n paperless_title = '{{ $json.paperless_title.replace(/'/g, \"''\") }}',\n status = 'uploaded',\n uploaded_at = '{{ $json.uploaded_at }}'::timestamptz,\n updated_at = NOW()\nWHERE intake_id = '{{ $json.intake_id }}';"
|
||||||
|
},
|
||||||
|
"id": "pg-mark-uploaded",
|
||||||
|
"name": "Postgres Mark Uploaded",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.6,
|
||||||
|
"position": [
|
||||||
|
120,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Shared Postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nreturn [{\n json: {\n ok: true,\n intake_id: $json.intake_id,\n status: 'uploaded',\n paperless_task_id: $json.paperless_task_id,\n paperless_document_id: $json.paperless_document_id,\n paperless_title: $json.paperless_title,\n stored_filename: $json.stored_filename,\n received_at: $json.received_at,\n uploaded_at: $json.uploaded_at\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "success-response",
|
||||||
|
"name": "Return Success Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
360,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"School Intake Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Normalize Intake Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Normalize Intake Request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Postgres Upsert Intake Received",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Postgres Upsert Intake Received": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upload to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upload to Paperless": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Capture Paperless Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Capture Paperless Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Postgres Mark Uploaded",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Postgres Mark Uploaded": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return Success Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": false,
|
||||||
|
"artifactVersion": "v1.1"
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "school"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "postgres"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "telegram"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
190
n8n-workflows/18-school-paperless-intake-v1.2.json
Normal file
190
n8n-workflows/18-school-paperless-intake-v1.2.json
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
{
|
||||||
|
"name": "Telegram School Intake \u2192 Postgres \u2192 Paperless v1.2",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "school/intake/upload",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {
|
||||||
|
"rawBody": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "school-intake-webhook",
|
||||||
|
"name": "School Intake Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-1180,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "6251b84e-2ac7-49a3-9b05-7ca6540da134"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst req = $input.first().json;\nconst body = req.body || {};\nconst binary = $input.first().binary || {};\nconst upload = binary.data || Object.values(binary)[0];\nif (!upload) throw new Error('Expected multipart file upload in the webhook request.');\nconst required = ['class_name', 'assignment_name', 'submission_kind'];\nfor (const key of required) {\n if (!body[key] || !String(body[key]).trim()) {\n throw new Error('Missing required form field: ' + key + ' . Required fields: class_name, assignment_name, submission_kind.');\n }\n}\nconst sanitize = (value, fallback = 'item') => String(value || fallback)\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 80) || fallback;\nconst stableHash = (str) => {\n let h1 = 0xdeadbeef;\n let h2 = 0x41c6ce57;\n for (let i = 0; i < str.length; i++) {\n const ch = str.charCodeAt(i);\n h1 = Math.imul(h1 ^ ch, 2654435761);\n h2 = Math.imul(h2 ^ ch, 1597334677);\n }\n h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);\n h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);\n return ((h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0'));\n};\nconst originalName = upload.fileName || body.filename || 'upload.bin';\nconst extension = (() => {\n const m = String(originalName).match(/(\\.[A-Za-z0-9]{1,10})$/);\n return m ? m[1].toLowerCase() : '';\n})();\nconst bytes = Number(upload.bytes ?? 0) || (() => {\n const text = String(upload.fileSize || '').trim();\n const m = text.match(/^([0-9.]+)\\s*(B|KB|MB|GB)$/i);\n if (!m) return 0;\n const value = Number(m[1]);\n const unit = m[2].toUpperCase();\n const mult = unit === 'GB' ? 1024**3 : unit === 'MB' ? 1024**2 : unit === 'KB' ? 1024 : 1;\n return Number.isFinite(value) ? Math.round(value * mult) : 0;\n})();\nconst checksum = stableHash([originalName, upload.mimeType || '', String(bytes), String(body.class_name), String(body.assignment_name), String(body.submission_kind)].join('|'));\nconst now = new Date();\nconst intakeId = ['school', now.toISOString().slice(0,10).replace(/-/g,''), sanitize(body.class_name), sanitize(body.assignment_name), checksum.slice(0, 12)].join('-');\nconst storedFilename = intakeId + extension;\nconst title = [body.class_name, body.assignment_name, body.submission_kind].map(v => String(v).trim()).join(' \u2014 ');\nreturn [{\n json: {\n intake_id: intakeId,\n class_name: String(body.class_name).trim(),\n assignment_name: String(body.assignment_name).trim(),\n submission_kind: String(body.submission_kind).trim(),\n semester: body.semester ? String(body.semester).trim() : null,\n course_code: body.course_code ? String(body.course_code).trim() : null,\n paper_kind: body.paper_kind ? String(body.paper_kind).trim() : null,\n notes: body.notes ? String(body.notes).trim().slice(0, 4000) : null,\n source: body.source ? String(body.source).trim() : 'telegram',\n telegram_chat_id: body.telegram_chat_id ? String(body.telegram_chat_id).trim() : null,\n telegram_message_id: body.telegram_message_id ? String(body.telegram_message_id).trim() : null,\n original_filename: originalName,\n stored_filename: storedFilename,\n mime_type: upload.mimeType || 'application/octet-stream',\n file_size_bytes: bytes,\n checksum_sha256: checksum,\n paperless_title: title,\n received_at: now.toISOString()\n },\n binary: {\n data: {\n ...upload,\n fileName: storedFilename,\n mimeType: upload.mimeType || 'application/octet-stream'\n }\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "normalize-request",
|
||||||
|
"name": "Normalize Intake Request",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://paperless.paccoco.com/api/documents/post_document/",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Token {{ $env.PAPERLESS_API_TOKEN }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "document",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ $json.paperless_title }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "paperless-upload",
|
||||||
|
"name": "Upload to Paperless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-400,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst intake = $('Normalize Intake Request').first().json;\nconst response = $json;\nconst taskId = response.task_id || response.id || response.document?.id || null;\nlet paperlessDocumentId = response.document_id || response.document?.id || null;\nif (!paperlessDocumentId && typeof response === 'object' && response?.id && !response?.task_id) {\n paperlessDocumentId = response.id;\n}\nreturn [{ json: {\n ...intake,\n paperless_task_id: taskId ? String(taskId) : null,\n paperless_document_id: paperlessDocumentId ? String(paperlessDocumentId) : null,\n paperless_response: response,\n uploaded_at: new Date().toISOString(),\n status: 'uploaded'\n}}];"
|
||||||
|
},
|
||||||
|
"id": "capture-paperless-response",
|
||||||
|
"name": "Capture Paperless Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-140,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nreturn [{\n json: {\n ok: true,\n intake_id: $json.intake_id,\n status: $json.status || 'uploaded',\n paperless_task_id: $json.paperless_task_id,\n paperless_document_id: $json.paperless_document_id,\n paperless_title: $json.paperless_title,\n stored_filename: $json.stored_filename,\n received_at: $json.received_at,\n uploaded_at: $json.uploaded_at\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "success-response",
|
||||||
|
"name": "Return Success Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
360,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"School Intake Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Normalize Intake Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Normalize Intake Request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Upload to Paperless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Upload to Paperless": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Capture Paperless Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Capture Paperless Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return Success Payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-13T21:15:56.032Z",
|
||||||
|
"createdAt": "2026-05-13T21:15:56.032Z",
|
||||||
|
"id": "h1moa2N6f4A1ArxD",
|
||||||
|
"name": "telegram"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T02:27:25.824Z",
|
||||||
|
"createdAt": "2026-05-07T02:27:25.824Z",
|
||||||
|
"id": "MfaTegnw8p0SsGGm",
|
||||||
|
"name": "postgres"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"id": "NxHjuO4MMd5kdPR7",
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"id": "Q9lCYtCOuy9XmeyL",
|
||||||
|
"name": "school"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
295
n8n-workflows/19-paperless-school-metadata-enrichment-v1.0.json
Normal file
295
n8n-workflows/19-paperless-school-metadata-enrichment-v1.0.json
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless School Intake Metadata Enrichment v1.0",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "school/intake/paperless-enrich",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries"
|
||||||
|
},
|
||||||
|
"id": "paperless-school-webhook",
|
||||||
|
"name": "Paperless School Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-1160,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 } }];"
|
||||||
|
},
|
||||||
|
"id": "normalize-paperless-webhook",
|
||||||
|
"name": "Normalize Paperless Webhook",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-900,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fetch-paperless-document",
|
||||||
|
"name": "Fetch Paperless Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-640,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst doc = $input.first().json || {};\nconst originalFilename = doc.original_file_name || doc.original_filename || '';\nconst baseName = String(originalFilename).replace(/\\.[^.]+$/, '');\nif (!baseName || !baseName.startsWith('school-')) {\n throw new Error('Document original filename does not contain expected intake id prefix: ' + originalFilename);\n}\nreturn [{ json: {\n intake_id: baseName,\n document_id: doc.id,\n current_title: doc.title || '',\n current_tags: Array.isArray(doc.tags) ? doc.tags : [],\n original_filename: originalFilename\n} }];"
|
||||||
|
},
|
||||||
|
"id": "extract-intake-id",
|
||||||
|
"name": "Extract Intake ID",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-380,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT intake_id, class_name, assignment_name, submission_kind, semester, course_code, paper_kind, notes, source, telegram_chat_id, telegram_message_id, original_filename, stored_filename, checksum_sha256, status, paperless_task_id, paperless_document_id, paperless_title, received_at, uploaded_at, enriched_at FROM school_paperless_intake WHERE intake_id = '{{ $json.intake_id.replace(/'/g, \"''\") }}' LIMIT 1;"
|
||||||
|
},
|
||||||
|
"id": "pg-fetch-intake-record",
|
||||||
|
"name": "Postgres Fetch Intake Record",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.6,
|
||||||
|
"position": [
|
||||||
|
-120,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Shared Postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst intake = $input.first().json || {};\nif (!intake.intake_id) throw new Error('No intake record found in Postgres.');\nconst doc = $('Fetch Paperless Document').first().json || {};\nconst classTagMap = JSON.parse($env.PAPERLESS_TAG_CLASS_MAP_JSON || '{}');\nconst kindTagMap = JSON.parse($env.PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON || '{}');\nconst paperKindDocTypeMap = JSON.parse($env.PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON || '{}');\nconst classCorrespondentMap = JSON.parse($env.PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON || '{}');\nconst currentTags = Array.isArray(doc.tags) ? doc.tags.map(Number).filter(Number.isFinite) : [];\nconst merged = new Set(currentTags);\nfor (const id of [$env.PAPERLESS_TAG_SCHOOL, $env.PAPERLESS_TAG_ASSIGNMENTS, $env.PAPERLESS_TAG_TELEGRAM]) {\n const n = Number(id);\n if (Number.isFinite(n) && n > 0) merged.add(n);\n}\nconst classTag = Number(classTagMap[intake.class_name]);\nif (Number.isFinite(classTag) && classTag > 0) merged.add(classTag);\nconst kindKey = String(intake.submission_kind || '').trim().toLowerCase();\nconst kindTag = Number(kindTagMap[kindKey]);\nif (Number.isFinite(kindTag) && kindTag > 0) merged.add(kindTag);\nconst title = [intake.class_name, intake.assignment_name, intake.submission_kind].filter(Boolean).join(' \u2014 ');\nconst payload = { title, tags: Array.from(merged) };\nconst documentType = Number(paperKindDocTypeMap[intake.paper_kind]);\nif (Number.isFinite(documentType) && documentType > 0) payload.document_type = documentType;\nconst correspondent = Number(classCorrespondentMap[intake.class_name]);\nif (Number.isFinite(correspondent) && correspondent > 0) payload.correspondent = correspondent;\nreturn [{ json: {\n intake_id: intake.intake_id,\n document_id: doc.id,\n payload,\n title,\n final_tag_ids: payload.tags,\n class_name: intake.class_name,\n assignment_name: intake.assignment_name,\n submission_kind: intake.submission_kind\n} }];"
|
||||||
|
},
|
||||||
|
"id": "build-paperless-update",
|
||||||
|
"name": "Build Paperless Update",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
140,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"jsonBody": "={{$json.payload}}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "update-paperless-document",
|
||||||
|
"name": "Update Paperless Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
400,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "UPDATE school_paperless_intake SET paperless_document_id = {{ Number($json.id || $('Build Paperless Update').first().json.document_id) }}, paperless_title = '{{ $('Build Paperless Update').first().json.title.replace(/'/g, \"''\") }}', status = 'enriched', enriched_at = NOW(), updated_at = NOW() WHERE intake_id = '{{ $('Build Paperless Update').first().json.intake_id.replace(/'/g, \"''\") }}';"
|
||||||
|
},
|
||||||
|
"id": "pg-mark-enriched",
|
||||||
|
"name": "Postgres Mark Enriched",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.6,
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"postgres": {
|
||||||
|
"id": "SETUP_REQUIRED",
|
||||||
|
"name": "Shared Postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst update = $('Build Paperless Update').first().json;\nreturn [{ json: {\n ok: true,\n intake_id: update.intake_id,\n document_id: update.document_id,\n title: update.title,\n final_tag_ids: update.final_tag_ids,\n status: 'enriched'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "return-enrichment-success",
|
||||||
|
"name": "Return Enrichment Success",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Paperless School Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Normalize Paperless Webhook",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Normalize Paperless Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Paperless Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Paperless Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Intake ID",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Intake ID": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Postgres Fetch Intake Record",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Postgres Fetch Intake Record": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Paperless Update",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Paperless Update": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Update Paperless Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Update Paperless Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Postgres Mark Enriched",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Postgres Mark Enriched": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return Enrichment Success",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": false,
|
||||||
|
"artifactVersion": "v1.0"
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "school"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "postgres"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
332
n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json
Normal file
332
n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless School Intake Metadata Enrichment v1.1",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "school/intake/paperless-enrich",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries"
|
||||||
|
},
|
||||||
|
"id": "paperless-school-webhook",
|
||||||
|
"name": "Paperless School Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-1160,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "22ec3671-3ad9-4c48-8720-2ba31b944671"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"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",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-900,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fetch-paperless-document",
|
||||||
|
"name": "Fetch Paperless Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-640,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst doc = $input.first().json || {};\nconst originalFilename = doc.original_file_name || doc.original_filename || '';\nconst baseName = String(originalFilename).replace(/\\.[^.]+$/, '');\nconst currentTitle = String(doc.title || '').trim();\nconst parts = currentTitle.split(' \u2014 ').map((p) => p.trim()).filter(Boolean);\nconst class_name = parts.length >= 3 ? parts[0] : null;\nconst submission_kind = parts.length >= 3 ? parts[parts.length - 1] : null;\nconst assignment_name = parts.length >= 3 ? parts.slice(1, -1).join(' \u2014 ') : null;\nreturn [{ json: {\n intake_id: baseName && baseName.startsWith('school-') ? baseName : null,\n document_id: doc.id,\n current_title: currentTitle,\n current_tags: Array.isArray(doc.tags) ? doc.tags : [],\n original_filename: originalFilename,\n class_name,\n assignment_name,\n submission_kind\n} }];"
|
||||||
|
},
|
||||||
|
"id": "extract-intake-id",
|
||||||
|
"name": "Extract Intake Metadata",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-380,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst intake = $input.first().json || {};\nconst doc = $('Fetch Paperless Document').first().json || {};\nconst classTagMap = JSON.parse($env.PAPERLESS_TAG_CLASS_MAP_JSON || '{}');\nconst kindTagMap = JSON.parse($env.PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON || '{}');\nconst classCorrespondentMap = JSON.parse($env.PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON || '{}');\nconst currentTags = Array.isArray(doc.tags) ? doc.tags.map(Number).filter(Number.isFinite) : [];\nconst merged = new Set(currentTags);\nfor (const id of [$env.PAPERLESS_TAG_SCHOOL, $env.PAPERLESS_TAG_ASSIGNMENTS, $env.PAPERLESS_TAG_TELEGRAM]) {\n const n = Number(id);\n if (Number.isFinite(n) && n > 0) merged.add(n);\n}\nconst classTag = Number(classTagMap[intake.class_name]);\nif (Number.isFinite(classTag) && classTag > 0) merged.add(classTag);\nconst kindKey = String(intake.submission_kind || '').trim().toLowerCase();\nconst kindTag = Number(kindTagMap[kindKey]);\nif (Number.isFinite(kindTag) && kindTag > 0) merged.add(kindTag);\nconst title = intake.current_title || [intake.class_name, intake.assignment_name, intake.submission_kind].filter(Boolean).join(' \u2014 ');\nconst payload = { title, tags: Array.from(merged) };\nconst correspondent = Number(classCorrespondentMap[intake.class_name]);\nif (Number.isFinite(correspondent) && correspondent > 0) payload.correspondent = correspondent;\nreturn [{ json: {\n intake_id: intake.intake_id,\n document_id: doc.id,\n payload,\n title,\n final_tag_ids: payload.tags,\n class_name: intake.class_name,\n assignment_name: intake.assignment_name,\n submission_kind: intake.submission_kind,\n original_filename: intake.original_filename,\n content: String(doc.content || '').slice(0, 12000),\n created: doc.created || null,\n status: 'enriched'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "build-paperless-update",
|
||||||
|
"name": "Build Paperless Update",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
140,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"jsonBody": "={{$json.payload}}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "update-paperless-document",
|
||||||
|
"name": "Update Paperless Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
400,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst update = $('Parse School Summary').first().json;\nreturn [{ json: {\n ok: true,\n intake_id: update.intake_id,\n document_id: update.document_id,\n title: update.title,\n final_tag_ids: update.final_tag_ids,\n ai_suggested_tags: update.ai_suggested_tags || [],\n status: update.status || 'enriched'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "return-enrichment-success",
|
||||||
|
"name": "Return Enrichment Success",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"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}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "school-ai-summary",
|
||||||
|
"name": "Generate School Summary",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst raw = $input.first().json?.choices?.[0]?.message?.content || '{}';\nconst meta = $('Build Paperless Update').first().json;\nlet parsed;\ntry {\n parsed = JSON.parse(raw);\n} catch (error) {\n parsed = {\n summary: 'School document ingested and tagged. Automated summary parsing failed, so this note was kept minimal.',\n course_context: [],\n suggested_tags: []\n };\n}\nconst contextLines = [\n `- Course: ${meta.class_name || 'Unknown'}`,\n meta.assignment_name ? `- D2L assignment: ${meta.assignment_name}` : null,\n meta.submission_kind ? `- Submission kind: ${meta.submission_kind}` : null,\n ...(Array.isArray(parsed.course_context) ? parsed.course_context.filter(Boolean).map(v => `- ${String(v).replace(/^[-\u2022]\\s*/, '').trim()}`) : [])\n].filter(Boolean);\nconst note = [\n '**School Intake Summary**',\n ...contextLines,\n '',\n '**Summary**',\n String(parsed.summary || '').trim() || 'No summary returned.'\n].join('\\n');\nconst aiTags = Array.isArray(parsed.suggested_tags)\n ? [...new Set(parsed.suggested_tags.map(t => String(t || '').trim().toLowerCase()).filter(Boolean))].slice(0, 6)\n : [];\nreturn [{ json: { ...meta, note, ai_suggested_tags: aiTags } }];"
|
||||||
|
},
|
||||||
|
"id": "school-parse-summary",
|
||||||
|
"name": "Parse School Summary",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/notes/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"jsonBody": "={{ { note: $json.note } }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "add-school-note",
|
||||||
|
"name": "Add School Note",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1180,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Paperless School Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Normalize Paperless Webhook",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Normalize Paperless Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Paperless Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Paperless Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Intake Metadata",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Intake Metadata": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Paperless Update",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Paperless Update": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Update Paperless Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Update Paperless Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Generate School Summary",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Generate School Summary": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse School Summary",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse School Summary": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Add School Note",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Add School Note": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return Enrichment Success",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": null,
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
332
n8n-workflows/19-paperless-school-metadata-enrichment.json
Normal file
332
n8n-workflows/19-paperless-school-metadata-enrichment.json
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless School Intake Metadata Enrichment v1.1",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "school/intake/paperless-enrich",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries"
|
||||||
|
},
|
||||||
|
"id": "paperless-school-webhook",
|
||||||
|
"name": "Paperless School Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-1160,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "22ec3671-3ad9-4c48-8720-2ba31b944671"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"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",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-900,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fetch-paperless-document",
|
||||||
|
"name": "Fetch Paperless Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-640,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst doc = $input.first().json || {};\nconst originalFilename = doc.original_file_name || doc.original_filename || '';\nconst baseName = String(originalFilename).replace(/\\.[^.]+$/, '');\nconst currentTitle = String(doc.title || '').trim();\nconst parts = currentTitle.split(' \u2014 ').map((p) => p.trim()).filter(Boolean);\nconst class_name = parts.length >= 3 ? parts[0] : null;\nconst submission_kind = parts.length >= 3 ? parts[parts.length - 1] : null;\nconst assignment_name = parts.length >= 3 ? parts.slice(1, -1).join(' \u2014 ') : null;\nreturn [{ json: {\n intake_id: baseName && baseName.startsWith('school-') ? baseName : null,\n document_id: doc.id,\n current_title: currentTitle,\n current_tags: Array.isArray(doc.tags) ? doc.tags : [],\n original_filename: originalFilename,\n class_name,\n assignment_name,\n submission_kind\n} }];"
|
||||||
|
},
|
||||||
|
"id": "extract-intake-id",
|
||||||
|
"name": "Extract Intake Metadata",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-380,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst intake = $input.first().json || {};\nconst doc = $('Fetch Paperless Document').first().json || {};\nconst classTagMap = JSON.parse($env.PAPERLESS_TAG_CLASS_MAP_JSON || '{}');\nconst kindTagMap = JSON.parse($env.PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON || '{}');\nconst classCorrespondentMap = JSON.parse($env.PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON || '{}');\nconst currentTags = Array.isArray(doc.tags) ? doc.tags.map(Number).filter(Number.isFinite) : [];\nconst merged = new Set(currentTags);\nfor (const id of [$env.PAPERLESS_TAG_SCHOOL, $env.PAPERLESS_TAG_ASSIGNMENTS, $env.PAPERLESS_TAG_TELEGRAM]) {\n const n = Number(id);\n if (Number.isFinite(n) && n > 0) merged.add(n);\n}\nconst classTag = Number(classTagMap[intake.class_name]);\nif (Number.isFinite(classTag) && classTag > 0) merged.add(classTag);\nconst kindKey = String(intake.submission_kind || '').trim().toLowerCase();\nconst kindTag = Number(kindTagMap[kindKey]);\nif (Number.isFinite(kindTag) && kindTag > 0) merged.add(kindTag);\nconst title = intake.current_title || [intake.class_name, intake.assignment_name, intake.submission_kind].filter(Boolean).join(' \u2014 ');\nconst payload = { title, tags: Array.from(merged) };\nconst correspondent = Number(classCorrespondentMap[intake.class_name]);\nif (Number.isFinite(correspondent) && correspondent > 0) payload.correspondent = correspondent;\nreturn [{ json: {\n intake_id: intake.intake_id,\n document_id: doc.id,\n payload,\n title,\n final_tag_ids: payload.tags,\n class_name: intake.class_name,\n assignment_name: intake.assignment_name,\n submission_kind: intake.submission_kind,\n original_filename: intake.original_filename,\n content: String(doc.content || '').slice(0, 12000),\n created: doc.created || null,\n status: 'enriched'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "build-paperless-update",
|
||||||
|
"name": "Build Paperless Update",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
140,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"jsonBody": "={{$json.payload}}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "update-paperless-document",
|
||||||
|
"name": "Update Paperless Document",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
400,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst update = $('Parse School Summary').first().json;\nreturn [{ json: {\n ok: true,\n intake_id: update.intake_id,\n document_id: update.document_id,\n title: update.title,\n final_tag_ids: update.final_tag_ids,\n ai_suggested_tags: update.ai_suggested_tags || [],\n status: update.status || 'enriched'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "return-enrichment-success",
|
||||||
|
"name": "Return Enrichment Success",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://10.5.1.6:4000/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"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}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "school-ai-summary",
|
||||||
|
"name": "Generate School Summary",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst raw = $input.first().json?.choices?.[0]?.message?.content || '{}';\nconst meta = $('Build Paperless Update').first().json;\nlet parsed;\ntry {\n parsed = JSON.parse(raw);\n} catch (error) {\n parsed = {\n summary: 'School document ingested and tagged. Automated summary parsing failed, so this note was kept minimal.',\n course_context: [],\n suggested_tags: []\n };\n}\nconst contextLines = [\n `- Course: ${meta.class_name || 'Unknown'}`,\n meta.assignment_name ? `- D2L assignment: ${meta.assignment_name}` : null,\n meta.submission_kind ? `- Submission kind: ${meta.submission_kind}` : null,\n ...(Array.isArray(parsed.course_context) ? parsed.course_context.filter(Boolean).map(v => `- ${String(v).replace(/^[-\u2022]\\s*/, '').trim()}`) : [])\n].filter(Boolean);\nconst note = [\n '**School Intake Summary**',\n ...contextLines,\n '',\n '**Summary**',\n String(parsed.summary || '').trim() || 'No summary returned.'\n].join('\\n');\nconst aiTags = Array.isArray(parsed.suggested_tags)\n ? [...new Set(parsed.suggested_tags.map(t => String(t || '').trim().toLowerCase()).filter(Boolean))].slice(0, 6)\n : [];\nreturn [{ json: { ...meta, note, ai_suggested_tags: aiTags } }];"
|
||||||
|
},
|
||||||
|
"id": "school-parse-summary",
|
||||||
|
"name": "Parse School Summary",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
920,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/notes/'}}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"jsonBody": "={{ { note: $json.note } }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "add-school-note",
|
||||||
|
"name": "Add School Note",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1180,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Paperless School Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Normalize Paperless Webhook",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Normalize Paperless Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch Paperless Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch Paperless Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Intake Metadata",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Intake Metadata": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build Paperless Update",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build Paperless Update": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Update Paperless Document",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Update Paperless Document": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Generate School Summary",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Generate School Summary": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse School Summary",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse School Summary": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Add School Note",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Add School Note": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Return Enrichment Success",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": null,
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -11,10 +11,13 @@
|
|||||||
| 05 | Paperless → RAG | `POST /webhook/paperless/rag-ingest` | Auto-ingests Paperless documents into Qdrant for RAG search; notifies via Gotify |
|
| 05 | Paperless → RAG | `POST /webhook/paperless/rag-ingest` | Auto-ingests Paperless documents into Qdrant for RAG search; notifies via Gotify |
|
||||||
| 06 | RSS Digest | Scheduled (every 6h) + `POST /webhook/scrape/summarize` | Fetches RSS feeds, deduplicates via Postgres, AI-summarizes per-feed, pushes to Gotify |
|
| 06 | RSS Digest | Scheduled (every 6h) + `POST /webhook/scrape/summarize` | Fetches RSS feeds, deduplicates via Postgres, AI-summarizes per-feed, pushes to Gotify |
|
||||||
| 07 | Git Commit Summarizer | `POST /webhook/git/push` | Receives git push webhooks, AI-summarizes changes, notifies via Gotify |
|
| 07 | Git Commit Summarizer | `POST /webhook/git/push` | Receives git push webhooks, AI-summarizes changes, notifies via Gotify |
|
||||||
| 08 | Class Recording → RAG | Watches `/mnt/data/class-recordings/` | Auto-transcribes new .wav files, extracts key notes, ingests into RAG by class, saves notes to Paperless, notifies via Gotify |
|
| 08 | Class Recording → RAG | `POST /webhook/class/upload` | Transcribes uploaded .wav, extracts key notes, ingests into RAG by class, saves notes to Paperless, notifies via Gotify |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
## How to Import
|
## How to Import
|
||||||
|
|
||||||
@@ -61,14 +64,52 @@ CREATE TABLE IF NOT EXISTS n8n_health_dedup (
|
|||||||
|
|
||||||
### n8n Environment Variables
|
### n8n Environment Variables
|
||||||
|
|
||||||
Add these to n8n's `.env` and run `sudo docker compose up -d` to apply:
|
Two steps required — both the `.env` file AND the `docker-compose.yaml` `environment:` section must be updated, then n8n restarted with `sudo docker compose --env-file .env down n8n && sudo docker compose --env-file .env up -d n8n`.
|
||||||
|
|
||||||
|
**automation/.env** — add these:
|
||||||
|
|
||||||
| Variable | Used by | How to get |
|
| Variable | Used by | How to get |
|
||||||
|----------|---------|------------|
|
|----------|---------|------------|
|
||||||
| `PAPERLESS_API_TOKEN` | 04, 08 | Paperless → Settings → API token |
|
| `PAPERLESS_API_TOKEN` | 04, 08, 18, 19 | Paperless → Settings → API token |
|
||||||
| `PAPERLESS_TAG_TRANSCRIPTION` | 04 | Tag ID from Paperless API |
|
| `PAPERLESS_TAG_TRANSCRIPTION` | 04 | Tag ID from Paperless API |
|
||||||
| `PAPERLESS_TAG_LECTURE_NOTES` | 08 | Tag ID from Paperless API |
|
| `PAPERLESS_TAG_LECTURE_NOTES` | 08 | Tag ID from Paperless API |
|
||||||
|
| `PAPERLESS_TAG_SCHOOL` | 19 | Generic school tag ID from Paperless API |
|
||||||
|
| `PAPERLESS_TAG_ASSIGNMENTS` | 19 | Generic assignments tag ID from Paperless API |
|
||||||
|
| `PAPERLESS_TAG_TELEGRAM` | 19 | Tag ID for Telegram-submitted items |
|
||||||
|
| `PAPERLESS_TAG_CLASS_MAP_JSON` | 19 | JSON object mapping class name → Paperless tag ID |
|
||||||
|
| `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 |
|
| `N8N_API_KEY` | 15 | n8n → Settings → API → Create API Key |
|
||||||
|
| `LITELLM_API_KEY` | 01, 04, 08 | LiteLLM virtual key (already in .env) |
|
||||||
|
|
||||||
|
**automation/docker-compose.yaml** — add to n8n `environment:` section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"
|
||||||
|
LITELLM_API_KEY: ${LITELLM_API_KEY}
|
||||||
|
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
|
||||||
|
PAPERLESS_TAG_TRANSCRIPTION: ${PAPERLESS_TAG_TRANSCRIPTION}
|
||||||
|
PAPERLESS_TAG_LECTURE_NOTES: ${PAPERLESS_TAG_LECTURE_NOTES}
|
||||||
|
PAPERLESS_TAG_SCHOOL: ${PAPERLESS_TAG_SCHOOL}
|
||||||
|
PAPERLESS_TAG_ASSIGNMENTS: ${PAPERLESS_TAG_ASSIGNMENTS}
|
||||||
|
PAPERLESS_TAG_TELEGRAM: ${PAPERLESS_TAG_TELEGRAM}
|
||||||
|
PAPERLESS_TAG_CLASS_MAP_JSON: ${PAPERLESS_TAG_CLASS_MAP_JSON}
|
||||||
|
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}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"` is required for workflows to read `$env.*` variables. Without it, env var access is silently denied even if the variable exists in the container.
|
||||||
|
|
||||||
### SSH Credential (required for workflow 16)
|
### SSH Credential (required for workflow 16)
|
||||||
|
|
||||||
@@ -114,16 +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"}'
|
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:
|
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/new-document
|
||||||
https://n8n.paccoco.com/webhook/paperless/rag-ingest
|
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>}`
|
with body: `{"document_id": <id>}`
|
||||||
|
|
||||||
You can trigger both from the same Paperless event — workflow 03 classifies/tags the document, workflow 05 ingests it into RAG.
|
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)
|
### Grafana Webhook (already configured)
|
||||||
|
|
||||||
@@ -140,6 +183,19 @@ Works with Gitea, Forgejo, GitHub, and GitLab webhook payloads.
|
|||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
|
### Upload schoolwork from Telegram intake:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://n8n.paccoco.com/webhook/school/intake/upload \
|
||||||
|
-F "document=@essay-draft.pdf" \
|
||||||
|
-F "class_name=English 101" \
|
||||||
|
-F "assignment_name=Essay Draft 1" \
|
||||||
|
-F "submission_kind=homework" \
|
||||||
|
-F "semester=Fall 2026" \
|
||||||
|
-F "course_code=ENG101" \
|
||||||
|
-F "paper_kind=essay"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Ingest a document into RAG:
|
### Ingest a document into RAG:
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://n8n.paccoco.com/webhook/rag/ingest \
|
curl -X POST https://n8n.paccoco.com/webhook/rag/ingest \
|
||||||
@@ -172,7 +228,7 @@ curl -X POST https://n8n.paccoco.com/webhook/transcribe/summarize \
|
|||||||
|
|
||||||
### Add a class recording (just drop a file):
|
### Add a class recording (just drop a file):
|
||||||
```
|
```
|
||||||
/mnt/data/class-recordings/
|
/mnt/tank/class-recordings/
|
||||||
├── CS101-Java/
|
├── CS101-Java/
|
||||||
│ ├── lecture-01-intro.wav
|
│ ├── lecture-01-intro.wav
|
||||||
│ ├── lecture-02-variables.wav
|
│ ├── lecture-02-variables.wav
|
||||||
@@ -183,8 +239,9 @@ curl -X POST https://n8n.paccoco.com/webhook/transcribe/summarize \
|
|||||||
└── essay-workshop.wav
|
└── essay-workshop.wav
|
||||||
```
|
```
|
||||||
|
|
||||||
The workflow detects the class from the subfolder name and the lecture title from the filename.
|
Workflow 08 uses a webhook trigger (`POST /webhook/class/upload`), not a folder watcher. Upload the file via curl and include the class name and lecture title as form fields (see curl example below).
|
||||||
Just drop a `.wav` file into the right class folder and it auto-processes.
|
|
||||||
|
> **Note:** The folder structure above shows suggested organization on disk. The workflow does not watch the folder — you must POST to the webhook.
|
||||||
|
|
||||||
### Query RAG for class-specific content:
|
### Query RAG for class-specific content:
|
||||||
```bash
|
```bash
|
||||||
@@ -197,20 +254,31 @@ The RAG system stores class name metadata with each chunk, so when you mention a
|
|||||||
|
|
||||||
### Class Recordings Setup (Workflow 08)
|
### Class Recordings Setup (Workflow 08)
|
||||||
|
|
||||||
Requires n8n volume mount to the NFS share. Add to n8n's docker-compose:
|
Create class subfolders on PD (already done for CS101-Java):
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- /mnt/tank/docker/appdata/n8n:/home/node/.n8n
|
|
||||||
- /mnt/data/class-recordings:/data/class-recordings
|
|
||||||
```
|
|
||||||
|
|
||||||
Create class subfolders:
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /mnt/data/class-recordings/CS101-Java
|
sudo mkdir -p /mnt/tank/class-recordings/CS101-Java
|
||||||
mkdir -p /mnt/data/class-recordings/MATH201
|
sudo mkdir -p /mnt/tank/class-recordings/MATH201
|
||||||
```
|
```
|
||||||
|
|
||||||
Then restart n8n and activate the workflow. Any `.wav` dropped into a subfolder triggers automatic transcription + RAG ingest.
|
Add to n8n's **automation/.env**:
|
||||||
|
```
|
||||||
|
PAPERLESS_TAG_LECTURE_NOTES=11
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to n8n's **automation/docker-compose.yaml** under the n8n `environment:` section:
|
||||||
|
```yaml
|
||||||
|
PAPERLESS_TAG_LECTURE_NOTES: ${PAPERLESS_TAG_LECTURE_NOTES}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart n8n and import/activate `08-class-recording-rag-v1.1.json`.
|
||||||
|
|
||||||
|
To upload a class recording:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://n8n.paccoco.com/webhook/class/upload \
|
||||||
|
-F "data=@lecture-01-intro.wav" \
|
||||||
|
-F "class_name=CS101-Java" \
|
||||||
|
-F "lecture_title=lecture-01-intro"
|
||||||
|
```
|
||||||
|
|
||||||
### Trigger RSS digest manually:
|
### Trigger RSS digest manually:
|
||||||
```bash
|
```bash
|
||||||
@@ -231,6 +299,32 @@ curl -X POST https://n8n.paccoco.com/webhook/git/push \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Workflow 18 — Telegram School Intake → Postgres → Paperless v1.2
|
||||||
|
|
||||||
|
Import `18-school-paperless-intake-v1.2.json` for a chat-driven schoolwork intake flow. Supporting notes live at `docs/operations/SCHOOL_PAPERLESS_INTAKE.md` and the tracked schema lives at `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`.
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
- multipart upload endpoint: `POST /webhook/school/intake/upload`
|
||||||
|
- required fields: `class_name`, `assignment_name`, `submission_kind`
|
||||||
|
- optional fields: `semester`, `course_code`, `paper_kind`, `notes`, `telegram_chat_id`, `telegram_message_id`
|
||||||
|
- sends deterministic `intake_id` in the Paperless filename and title correlation data
|
||||||
|
- returns upload success context immediately from the webhook
|
||||||
|
- v1.1 removed the `require('crypto')` dependency so the workflow can run without adding `crypto` to `NODE_FUNCTION_ALLOW_BUILTIN`
|
||||||
|
- v1.2 fixes the multipart Paperless upload node to use n8n's correct `inputDataFieldName` pattern for binary form uploads
|
||||||
|
|
||||||
|
Testing fixture:
|
||||||
|
- `fixtures/school-paperless-intake-webhook.curl.example.txt`
|
||||||
|
|
||||||
|
## Workflow Versioning Convention
|
||||||
|
|
||||||
|
For n8n workflow artifacts in this repo:
|
||||||
|
- include the version in the **filename** (example: `18-school-paperless-intake-v1.1.json`)
|
||||||
|
- include the version in the workflow's internal **`name`** field
|
||||||
|
- bump the version whenever behavior, schema expectations, webhook payload handling, or env requirements change
|
||||||
|
- update `n8n-workflows/README.md` and `n8n-workflows/TESTING-STATUS.md` when introducing a new workflow version
|
||||||
|
|
||||||
|
Do not overwrite a workflow artifact with silent behavior changes and no version bump. That way lies confusion and sweaty guesswork. Ha ha.
|
||||||
|
|
||||||
## Customizing RSS Feeds (Workflow 06)
|
## Customizing RSS Feeds (Workflow 06)
|
||||||
|
|
||||||
Edit the "Define RSS Feeds" Code node to add/remove feeds. Default feeds:
|
Edit the "Define RSS Feeds" Code node to add/remove feeds. Default feeds:
|
||||||
@@ -240,3 +334,111 @@ Edit the "Define RSS Feeds" Code node to add/remove feeds. Default feeds:
|
|||||||
- Hacker News
|
- Hacker News
|
||||||
|
|
||||||
Add any RSS feed URL you want monitored.
|
Add any RSS feed URL you want monitored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow 17 — Paperless Intake Triage → Doris Review Queue
|
||||||
|
|
||||||
|
Import `17-paperless-intake-triage.json` for a review-first Paperless intake workflow. It is intentionally conservative: it writes a sanitized local review queue for Doris Dashboard and does **not** delete documents. Automatic Paperless updates are disabled by default.
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
|
||||||
|
Configure a Paperless-NGX post-consumption webhook to POST to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://n8n.paccoco.com/webhook/paperless/intake-triage
|
||||||
|
```
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"document_id": 12345}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixture: `fixtures/paperless-intake-webhook.example.json`.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Add these to `automation/.env` and expose them in the n8n `environment:` block:
|
||||||
|
|
||||||
|
| Variable | Required | Default | Purpose |
|
||||||
|
|----------|----------|---------|---------|
|
||||||
|
| `PAPERLESS_BASE_URL` | yes | `https://paperless.paccoco.com` | Paperless API/UI base URL |
|
||||||
|
| `PAPERLESS_API_TOKEN` | yes | none | Paperless API token; used as `Token ...` |
|
||||||
|
| `LITELLM_BASE_URL` | yes | `http://10.5.1.6:4000` | LiteLLM OpenAI-compatible endpoint |
|
||||||
|
| `LITELLM_API_KEY` | yes | none | LiteLLM key |
|
||||||
|
| `PAPERLESS_TRIAGE_MODEL` | no | `gpt-4o-mini` | Model for strict JSON triage |
|
||||||
|
| `PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH` | yes | `/data/paperless_review.json` | Sanitized JSON queue path mounted for Doris Dashboard |
|
||||||
|
| `PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS` | no | `200` | Max queue entries retained |
|
||||||
|
| `PAPERLESS_TRIAGE_APPLY_SAFE` | no | `false` | Enables low-risk title-only update branch when `true` |
|
||||||
|
| `GOTIFY_URL` | no | none | Enables urgent-doc notification only when token also set |
|
||||||
|
| `GOTIFY_TOKEN` | no | none | Gotify app token for urgent docs |
|
||||||
|
| `NODE_FUNCTION_ALLOW_BUILTIN` | yes for queue file | none | Must include `fs,path` for the Code node that writes JSON |
|
||||||
|
| `N8N_BLOCK_ENV_ACCESS_IN_NODE` | yes | existing note | Must be `false` so Code nodes can read `$env` |
|
||||||
|
|
||||||
|
Example environment additions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"
|
||||||
|
NODE_FUNCTION_ALLOW_BUILTIN: fs,path
|
||||||
|
PAPERLESS_BASE_URL: ${PAPERLESS_BASE_URL}
|
||||||
|
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
|
||||||
|
LITELLM_BASE_URL: ${LITELLM_BASE_URL}
|
||||||
|
LITELLM_API_KEY: ${LITELLM_API_KEY}
|
||||||
|
PAPERLESS_TRIAGE_MODEL: ${PAPERLESS_TRIAGE_MODEL}
|
||||||
|
PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH: /workspace/doris-dashboard/data/paperless_review.json
|
||||||
|
PAPERLESS_TRIAGE_APPLY_SAFE: "false"
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount the Doris dashboard data directory read/write into the n8n container if it is not already mounted. The workflow writes only sanitized display data: document IDs, titles, filenames, archive URL, current metadata names/IDs, triage summary, review reason, urgency, confidence, and safety flags. It must not contain tokens.
|
||||||
|
|
||||||
|
### Safety gates
|
||||||
|
|
||||||
|
The LLM must return strict JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "plain English summary",
|
||||||
|
"suggested_title": "clean title",
|
||||||
|
"document_type": "bill|receipt|school|medical|tax|insurance|legal|manual|letter|other",
|
||||||
|
"suggested_correspondent": "string or null",
|
||||||
|
"suggested_tags": ["..."],
|
||||||
|
"due_date": "YYYY-MM-DD or null",
|
||||||
|
"needs_review": true,
|
||||||
|
"urgency": "none|low|medium|high",
|
||||||
|
"reason": "why review/why not",
|
||||||
|
"confidence": "high|medium|low"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow forcibly marks `needs_review=true` for:
|
||||||
|
|
||||||
|
- legal, medical, school, tax, or insurance documents
|
||||||
|
- any due date
|
||||||
|
- bill/payment/invoice language
|
||||||
|
- low confidence
|
||||||
|
- urgency medium or high
|
||||||
|
|
||||||
|
Only high-confidence, low-risk docs with no due date and urgency `none|low` become eligible for automatic update, and even then the branch is disabled unless `PAPERLESS_TRIAGE_APPLY_SAFE=true`. The safe update branch currently updates **title only**. It does not delete, archive, or apply high-risk metadata.
|
||||||
|
|
||||||
|
### Test payload and expected result
|
||||||
|
|
||||||
|
- Webhook payload: `fixtures/paperless-intake-webhook.example.json`
|
||||||
|
- Example Paperless API document: `fixtures/paperless-intake-document.example.json`
|
||||||
|
- Expected triage JSON: `fixtures/paperless-intake-expected-triage.example.json`
|
||||||
|
|
||||||
|
The fixture is a utility bill with a due date, so the safety gate must force human review.
|
||||||
|
|
||||||
|
### Import/deploy steps
|
||||||
|
|
||||||
|
1. Import `17-paperless-intake-triage.json` in n8n.
|
||||||
|
2. Add env vars and required volume mount.
|
||||||
|
3. Restart n8n using the normal automation compose procedure.
|
||||||
|
4. Keep `PAPERLESS_TRIAGE_APPLY_SAFE=false` for initial testing.
|
||||||
|
5. Use a known test document and POST `{"document_id": <id>}` to `/webhook/paperless/intake-triage`.
|
||||||
|
6. Confirm `doris-dashboard/data/paperless_review.json` updates and contains no secrets.
|
||||||
|
7. Activate the Paperless webhook only after the test queue file is correct.
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Deactivate the workflow and remove the Paperless webhook target. If needed, move `doris-dashboard/data/paperless_review.json` aside; it is display-only derived data. No Paperless deletion path exists in this workflow.
|
||||||
|
|||||||
142
n8n-workflows/TESTING-STATUS.md
Normal file
142
n8n-workflows/TESTING-STATUS.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# n8n Workflow Testing Status
|
||||||
|
_Last updated: 2026-05-13_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Confirmed Working
|
||||||
|
|
||||||
|
| # | Workflow | Version | Notes |
|
||||||
|
|---|----------|---------|-------|
|
||||||
|
| 01 | Grafana Alert → AI + Gotify | v1.4 | Dedup + recovery branch confirmed |
|
||||||
|
| 08 | Class Recording RAG | v1.2 | Paperless + Gotify end-to-end confirmed |
|
||||||
|
| 09 | Class Transcript Ingest | v1.1 | Gotify confirmed |
|
||||||
|
| 14 | Paperless Inbox Reminder | v1.2 | Fixed: `Bearer` → `Token` auth; fixed: `hasItemsStr` bug |
|
||||||
|
| 15 | n8n Health Monitor | v1.2 | Fixed: workflow names (was showing IDs); Postgres dedup working |
|
||||||
|
| 16 | NFS Watchdog + Auto-Heal | v1.2 | Confirmed working |
|
||||||
|
| 07 | Git Commit → AI Summary → Gotify | v1.0 | Confirmed working |
|
||||||
|
|
||||||
|
## ⏸ Retired
|
||||||
|
|
||||||
|
| # | Workflow | Reason |
|
||||||
|
|---|----------|--------|
|
||||||
|
| 04 | Whisper Transcription | Replaced — transcription now runs via Docker on Rocinante (RTX 4090) and sends directly to n8n |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔲 Not Yet Tested
|
||||||
|
|
||||||
|
### 02 — RAG Pipeline v1.0
|
||||||
|
**What it does:** Two webhooks — `POST /rag/ingest` chunks + embeds text into Qdrant; `POST /rag/query` embeds query, searches Qdrant, asks LiteLLM, returns answer.
|
||||||
|
|
||||||
|
**Pre-test checklist:**
|
||||||
|
- [ ] `knowledge_base` collection exists in Qdrant (`GET http://10.5.1.6:6333/collections`)
|
||||||
|
- [ ] `nomic-embed-text` model pulled in Ollama (`ollama list` on PD)
|
||||||
|
- [ ] **Hardcoded LiteLLM key** in `LiteLLM Answer` node — should use `$env.LITELLM_API_KEY`
|
||||||
|
|
||||||
|
**Test approach:** POST `{"text": "some test content", "source": "test"}` to `/rag/ingest`, then POST `{"query": "test content question"}` to `/rag/query` and verify an answer comes back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 03 — Paperless AI Processing v1.0
|
||||||
|
**What it does:** Paperless webhook on new document → AI classifies (type/summary/tags) → adds note to doc + sends Gotify + applies tags (parallel branches after `Parse Analysis`).
|
||||||
|
|
||||||
|
**Pre-test checklist:**
|
||||||
|
- [ ] Paperless webhook configured to POST to `https://n8n.paccoco.com/webhook/paperless/new-document` on document add
|
||||||
|
- [ ] **Hardcoded Paperless token** in `Fetch Document`, `Add Note to Document`, and inline in `Apply Suggested Tags to Paperless` code — should use `$env.PAPERLESS_API_TOKEN`
|
||||||
|
- [ ] **Hardcoded LiteLLM key** in `AI Classify & Extract` — should use `$env.LITELLM_API_KEY`
|
||||||
|
- [ ] Gotify credential `ajvRjvj0QldLQYmo` ("Paperless") — already wired ✓
|
||||||
|
|
||||||
|
**Test approach:** Upload a test document to Paperless, then manually POST `{"document_id": <id>}` to the webhook to trigger processing. Check Paperless for note + tags, check Gotify for notification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 05 — Paperless → RAG v1.0
|
||||||
|
**What it does:** Webhook on `POST /paperless/rag-ingest` → fetches Paperless doc → chunks + embeds content → upserts to Qdrant `knowledge_base` → Gotify notification.
|
||||||
|
|
||||||
|
**Pre-test checklist:**
|
||||||
|
- [ ] `knowledge_base` collection exists in Qdrant (same as 02)
|
||||||
|
- [ ] **Hardcoded Paperless token** in `Fetch Document` — should use `$env.PAPERLESS_API_TOKEN`
|
||||||
|
- [ ] Gotify credential `SETUP_REQUIRED` ("Paperless RAG") — needs wiring
|
||||||
|
|
||||||
|
**Test approach:** POST `{"document_id": <id>}` to the webhook. Verify chunks appear in Qdrant and Gotify notification fires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 06 — RSS Digest v1.0
|
||||||
|
**What it does:** Every 6 hours (+ manual webhook + manual trigger), fetches 33 RSS feeds, parses items, deduplicates via Postgres (`rss_seen_items` table), scores by keyword relevance/urgency, AI-summarizes new items per feed, pushes per-feed digests to Gotify.
|
||||||
|
|
||||||
|
**Pre-test checklist:**
|
||||||
|
- [ ] `rss_seen_items` table exists in Postgres
|
||||||
|
- [ ] **Hardcoded LiteLLM key** in `AI Summarize Feed` node — should use `$env.LITELLM_API_KEY`
|
||||||
|
- [ ] Postgres credential `n9svoXemqSZoNNUB` — already wired ✓
|
||||||
|
- [ ] Gotify credential `ExUx3oqYQyhkqZcp` ("Rss Feed") — already wired ✓
|
||||||
|
|
||||||
|
**Test approach:** Use the "When clicking Execute workflow" manual trigger. First run will insert all items as new (expect lots of Gotify notifications). Second run should be silent (all deduped).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Known Issues / Tech Debt
|
||||||
|
|
||||||
|
| Workflow | Issue | Priority |
|
||||||
|
|----------|-------|----------|
|
||||||
|
| 02 | Hardcoded LiteLLM API key | Medium |
|
||||||
|
| 03 | Hardcoded LiteLLM API key + Paperless token | Medium |
|
||||||
|
| 05 | Hardcoded Paperless token | Medium |
|
||||||
|
| 06 | Hardcoded LiteLLM API key | Medium |
|
||||||
|
| 07 | Hardcoded LiteLLM API key (working but tech debt) | Low |
|
||||||
|
| 15 | `includeData: false` means error messages show as "Unknown error" | Low |
|
||||||
|
| 09 v1.0 | Still active in n8n alongside v1.1 — should be deactivated | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17 — Paperless Intake Triage → Doris Review Queue v1.0
|
||||||
|
**What it does:** Paperless post-consumption webhook → fetch document → LiteLLM strict JSON triage → enforce review-first safety gate → write sanitized Doris Dashboard queue JSON → optional urgent Gotify notification. Safe title-only Paperless update branch exists but is disabled unless `PAPERLESS_TRIAGE_APPLY_SAFE=true`.
|
||||||
|
|
||||||
|
**Local artifact checks completed:**
|
||||||
|
- [x] Workflow JSON validates with `python3 -m json.tool`
|
||||||
|
- [x] Fixture JSON files validate with `python3 -m json.tool`
|
||||||
|
- [x] Dashboard example queue JSON validates with `python3 -m json.tool`
|
||||||
|
|
||||||
|
**Pre-test checklist:**
|
||||||
|
- [ ] Paperless webhook configured to POST `https://n8n.paccoco.com/webhook/paperless/intake-triage`
|
||||||
|
- [ ] `PAPERLESS_API_TOKEN` and `LITELLM_API_KEY` exposed to n8n through env vars; no hardcoded secrets
|
||||||
|
- [ ] `NODE_FUNCTION_ALLOW_BUILTIN=fs,path` set for queue file writer
|
||||||
|
- [ ] n8n has a writeable mount to `doris-dashboard/data/paperless_review.json`
|
||||||
|
- [ ] `PAPERLESS_TRIAGE_APPLY_SAFE=false` during initial validation
|
||||||
|
|
||||||
|
**Test approach:** Upload a harmless test document to Paperless, POST `{"document_id": <id>}` to the webhook, and confirm `doris-dashboard/data/paperless_review.json` contains a sanitized review item. Use a bill/due-date fixture to verify forced human review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 18 — Telegram School Intake → Postgres → Paperless v1.2
|
||||||
|
**What it does:** Accepts multipart school-work upload plus metadata (`class_name`, `assignment_name`, `submission_kind`, optional semester/course_code/paper_kind), uploads it into Paperless with a deterministic `intake_id`-based filename/title, and returns webhook success context.
|
||||||
|
|
||||||
|
**Local artifact checks completed:**
|
||||||
|
- [x] Workflow JSON validates with `python3 -m json.tool`
|
||||||
|
- [x] Companion helper script validates with `node --check school/intake/submit_to_n8n.js`
|
||||||
|
- [x] Class profile example JSON validates with `python3 -m json.tool`
|
||||||
|
- [x] v1.1 removes the `require('crypto')` dependency for easier live rollout
|
||||||
|
- [x] v1.2 fixes the Paperless multipart upload node to use `inputDataFieldName` for binary form upload
|
||||||
|
- [x] Live-tested 2026-05-14 with `Why am I here` DOCX; Paperless created document `49`
|
||||||
|
|
||||||
|
**Pre-test checklist:**
|
||||||
|
- [x] `PAPERLESS_API_TOKEN` exposed to n8n through env vars
|
||||||
|
- [x] Webhook path `https://n8n.paccoco.com/webhook/school/intake/upload` reachable from Doris/OpenClaw helper
|
||||||
|
|
||||||
|
**Test approach:** POST a small DOCX/PDF through the intake webhook and verify the document lands in Paperless with filename prefix `school-...` and deterministic title.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 19 — Paperless School Intake Metadata Enrichment v1.1
|
||||||
|
**What it does:** Receives Paperless post-consumption webhook for intake-managed documents, resolves the deterministic `intake_id` from the stored filename, derives metadata from deterministic filename/title, then reapplies deterministic title/tags and optional correspondent mapping.
|
||||||
|
|
||||||
|
**Local artifact checks completed:**
|
||||||
|
- [x] Workflow JSON validates with `python3 -m json.tool`
|
||||||
|
|
||||||
|
**Pre-test checklist:**
|
||||||
|
- [x] Paperless webhook target configured to POST `https://n8n.paccoco.com/webhook/school/intake/paperless-enrich`
|
||||||
|
- [x] Workflow 18 working so intake-managed filenames exist in Paperless
|
||||||
|
- [x] `PAPERLESS_API_TOKEN` exposed to n8n
|
||||||
|
- [ ] Optional mapping env vars supplied when ready: `PAPERLESS_TAG_SCHOOL`, `PAPERLESS_TAG_ASSIGNMENTS`, `PAPERLESS_TAG_TELEGRAM`, `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON`
|
||||||
|
|
||||||
|
**Test approach:** After a workflow-18 upload is consumed by Paperless, POST `{"document_id": <id>}` to `/webhook/school/intake/paperless-enrich` and verify title/tags enrichment succeeds. Live-tested on document `49`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"title": "scan_2026_05_12",
|
||||||
|
"original_filename": "utility_statement_may.pdf",
|
||||||
|
"created": "2026-05-12",
|
||||||
|
"added": "2026-05-13T02:14:00Z",
|
||||||
|
"correspondent_detail": {"name": "CDE Lightband"},
|
||||||
|
"document_type_detail": {"name": "Statement"},
|
||||||
|
"tags_detail": [{"name": "inbox"}],
|
||||||
|
"content": "CDE Lightband monthly utility statement. Amount due $142.17. Payment due by 2026-06-05."
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"summary": "Monthly utility bill from CDE Lightband for $142.17 due on 2026-06-05.",
|
||||||
|
"suggested_title": "CDE Lightband Utility Bill - May 2026",
|
||||||
|
"document_type": "bill",
|
||||||
|
"suggested_correspondent": "CDE Lightband",
|
||||||
|
"suggested_tags": ["paperless-triage", "utility", "bill"],
|
||||||
|
"due_date": "2026-06-05",
|
||||||
|
"needs_review": true,
|
||||||
|
"urgency": "medium",
|
||||||
|
"reason": "Contains a payment due date; safety gate requires human review.",
|
||||||
|
"confidence": "high"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"document_id": 12345
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
curl -X POST https://n8n.paccoco.com/webhook/school/intake/upload \
|
||||||
|
-F "document=@essay-draft.pdf" \
|
||||||
|
-F "class_name=English 101" \
|
||||||
|
-F "assignment_name=Essay Draft 1" \
|
||||||
|
-F "submission_kind=homework" \
|
||||||
|
-F "semester=Fall 2026" \
|
||||||
|
-F "course_code=ENG101" \
|
||||||
|
-F "paper_kind=essay" \
|
||||||
|
-F "notes=Submitted from Telegram chat intake" \
|
||||||
|
-F "source=telegram" \
|
||||||
|
-F "telegram_chat_id=123456789" \
|
||||||
|
-F "telegram_message_id=987654321"
|
||||||
30
pihole/.env.example
Normal file
30
pihole/.env.example
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
TZ=America/Chicago
|
||||||
|
|
||||||
|
# PD primary Pi-hole / Unbound paths
|
||||||
|
APPDATA_PIHOLE_ROOT=/mnt/tank/docker/appdata/pihole-primary
|
||||||
|
APPDATA_UNBOUND_ROOT=/mnt/tank/docker/appdata/unbound-primary
|
||||||
|
|
||||||
|
# Pi-hole web/admin
|
||||||
|
PD_PIHOLE_WEB_PORT=8953
|
||||||
|
PIHOLE_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
# Keepalived / VIP (VRRP PASS auth is max 8 chars)
|
||||||
|
LAN_INTERFACE=eno1
|
||||||
|
VIP_CIDR=10.5.1.53/24
|
||||||
|
KEEPALIVED_ROUTER_ID=53
|
||||||
|
KEEPALIVED_STATE=MASTER
|
||||||
|
KEEPALIVED_PRIORITY=100
|
||||||
|
KEEPALIVED_PASSWORD=CHANGE01
|
||||||
|
KEEPALIVED_NOPREEMPT=true
|
||||||
|
|
||||||
|
# Nebula Sync (PD primary -> replicas)
|
||||||
|
NEBULA_SYNC_PRIMARY=http://127.0.0.1:8953|CHANGE_ME
|
||||||
|
NEBULA_SYNC_REPLICAS=http://10.5.1.16:8954|CHANGE_ME
|
||||||
|
NEBULA_SYNC_FULL_SYNC=false
|
||||||
|
NEBULA_SYNC_RUN_GRAVITY=true
|
||||||
|
NEBULA_SYNC_CRON=0 3 * * *
|
||||||
|
NEBULA_SYNC_GRAVITY_GROUP=true
|
||||||
|
NEBULA_SYNC_GRAVITY_AD_LIST=true
|
||||||
|
NEBULA_SYNC_GRAVITY_AD_LIST_BY_GROUP=true
|
||||||
|
NEBULA_SYNC_GRAVITY_DOMAIN_LIST=true
|
||||||
|
NEBULA_SYNC_GRAVITY_DOMAIN_LIST_BY_GROUP=true
|
||||||
50
pihole/README.md
Normal file
50
pihole/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# PD Pi-hole / Unbound / Keepalived Stack
|
||||||
|
|
||||||
|
Primary-node stack for the Pi-hole HA plan.
|
||||||
|
|
||||||
|
- **Repo stack path:** `/home/fizzlepoof/repos/truenas-stacks/pihole`
|
||||||
|
- **Live PD stack path:** `/mnt/docker-ssd/docker/compose/pihole`
|
||||||
|
- **Node role:** PD primary / MASTER
|
||||||
|
- **VIP target:** `10.5.1.53/24`
|
||||||
|
|
||||||
|
This stack intentionally covers the **PD primary** plus PD-hosted sync:
|
||||||
|
- Pi-hole
|
||||||
|
- Unbound
|
||||||
|
- Keepalived
|
||||||
|
- Nebula Sync
|
||||||
|
|
||||||
|
NOMAD and RPi4 replicas still need their own deployment steps.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `docker-compose.yaml` — primary-node stack
|
||||||
|
- `.env.example` — required variables
|
||||||
|
- `unbound.conf` — shared recursive resolver config
|
||||||
|
- `keepalived.conf.template` — rendered into `keepalived.conf`
|
||||||
|
- `bin/render_keepalived_conf.py` — generates `keepalived.conf` from `.env`
|
||||||
|
- `bin/prepare_pd.sh` — creates appdata dirs, downloads root hints, renders config, validates compose, optional `--up`
|
||||||
|
|
||||||
|
## First-time setup on PD
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/docker-ssd/docker/compose/pihole
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # set real passwords + verify LAN_INTERFACE
|
||||||
|
/usr/bin/bash bin/prepare_pd.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If that validates cleanly, bring it up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/bin/bash bin/prepare_pd.sh --up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important notes
|
||||||
|
|
||||||
|
- `KEEPALIVED_PASSWORD` must be **8 chars or fewer** because VRRP PASS auth is annoying and old.
|
||||||
|
- Verify `LAN_INTERFACE` on PD before deployment (`ip link show`).
|
||||||
|
- `FTLCONF_dns_listeningMode=LOCAL` is set because TrueNAS binds loopback on port 53.
|
||||||
|
- Pi-hole NTP is explicitly disabled here because PD host `chronyd` already owns UDP 123.
|
||||||
|
- Nebula Sync runs on PD and uses `NEBULA_SYNC_PRIMARY` / `NEBULA_SYNC_REPLICAS` from `.env`.
|
||||||
|
- Nebula Sync is intentionally set to selective mode with gravity sync toggles enabled for groups, adlists, and domain lists so replica-specific runtime/network settings are not clobbered.
|
||||||
|
- `keepalived.conf` is generated and should stay local/untracked.
|
||||||
60
pihole/bin/prepare_pd.sh
Executable file
60
pihole/bin/prepare_pd.sh
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
STACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-$STACK_DIR/.env}"
|
||||||
|
DO_UP="${1:-}"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing $ENV_FILE — copy .env.example to .env and fill in real values first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
required_vars=(
|
||||||
|
APPDATA_PIHOLE_ROOT
|
||||||
|
APPDATA_UNBOUND_ROOT
|
||||||
|
LAN_INTERFACE
|
||||||
|
VIP_CIDR
|
||||||
|
KEEPALIVED_PASSWORD
|
||||||
|
PD_PIHOLE_WEB_PORT
|
||||||
|
)
|
||||||
|
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 \
|
||||||
|
"$APPDATA_PIHOLE_ROOT/etc-pihole" \
|
||||||
|
"$APPDATA_PIHOLE_ROOT/etc-dnsmasq.d" \
|
||||||
|
"$APPDATA_UNBOUND_ROOT/var"
|
||||||
|
|
||||||
|
UNBOUND_TARGET="$APPDATA_UNBOUND_ROOT/unbound.conf"
|
||||||
|
UNBOUND_TMP="$APPDATA_UNBOUND_ROOT/.unbound.conf.tmp.$$"
|
||||||
|
cp "$STACK_DIR/unbound.conf" "$UNBOUND_TMP"
|
||||||
|
mv -f "$UNBOUND_TMP" "$UNBOUND_TARGET"
|
||||||
|
|
||||||
|
if [[ ! -s "$APPDATA_UNBOUND_ROOT/var/root.hints" ]]; then
|
||||||
|
curl -fsSL -o "$APPDATA_UNBOUND_ROOT/var/root.hints" https://www.internic.net/domain/named.cache
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV_FILE="$ENV_FILE" python3 "$STACK_DIR/bin/render_keepalived_conf.py"
|
||||||
|
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" config >/dev/null
|
||||||
|
|
||||||
|
echo "PD Pi-hole stack prepared at $STACK_DIR"
|
||||||
|
echo "- unbound config copied to $APPDATA_UNBOUND_ROOT/unbound.conf"
|
||||||
|
echo "- root hints present at $APPDATA_UNBOUND_ROOT/var/root.hints"
|
||||||
|
echo "- keepalived.conf rendered"
|
||||||
|
|
||||||
|
if [[ "$DO_UP" == "--up" ]]; then
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" up -d
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" ps
|
||||||
|
fi
|
||||||
73
pihole/bin/render_keepalived_conf.py
Executable file
73
pihole/bin/render_keepalived_conf.py
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STACK_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
ENV_FILE = Path(os.environ.get('ENV_FILE', STACK_DIR / '.env'))
|
||||||
|
TEMPLATE = STACK_DIR / 'keepalived.conf.template'
|
||||||
|
OUTPUT = STACK_DIR / 'keepalived.conf'
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path: Path) -> dict[str, str]:
|
||||||
|
env = dict(os.environ)
|
||||||
|
if path.exists():
|
||||||
|
for raw in path.read_text(encoding='utf-8').splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith('#') or '=' not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
env.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
env = load_env(ENV_FILE)
|
||||||
|
required = [
|
||||||
|
'LAN_INTERFACE', 'VIP_CIDR', 'KEEPALIVED_ROUTER_ID', 'KEEPALIVED_STATE',
|
||||||
|
'KEEPALIVED_PRIORITY', 'KEEPALIVED_PASSWORD'
|
||||||
|
]
|
||||||
|
missing = [k for k in required if not env.get(k)]
|
||||||
|
if missing:
|
||||||
|
print(f'Missing required vars in {ENV_FILE}: {", ".join(missing)}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
web_port = env.get('PIHOLE_WEB_PORT') or env.get('PD_PIHOLE_WEB_PORT') or env.get('SERENITY_PIHOLE_WEB_PORT') or env.get('RPI4_PIHOLE_WEB_PORT')
|
||||||
|
if not web_port:
|
||||||
|
print('Missing Pi-hole web port in .env (set PIHOLE_WEB_PORT or node-specific port variable).', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
keepalived_password = env['KEEPALIVED_PASSWORD']
|
||||||
|
if len(keepalived_password) > 8:
|
||||||
|
print('KEEPALIVED_PASSWORD must be 8 characters or fewer for VRRP PASS auth.', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
web_port_hex = f"{int(web_port):04X}"
|
||||||
|
except ValueError:
|
||||||
|
print('Pi-hole web port must be an integer.', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
router_id_name = f"pihole_{re.sub(r'[^a-z0-9]+', '_', env['KEEPALIVED_STATE'].lower())}"
|
||||||
|
nopreempt_line = ' nopreempt' if env.get('KEEPALIVED_NOPREEMPT', 'true').lower() in {'1','true','yes','on'} else ''
|
||||||
|
|
||||||
|
rendered = TEMPLATE.read_text(encoding='utf-8')
|
||||||
|
replacements = {
|
||||||
|
'ROUTER_ID_NAME': router_id_name,
|
||||||
|
'PIHOLE_WEB_PORT_HEX': web_port_hex,
|
||||||
|
'NOPREEMPT_LINE': nopreempt_line,
|
||||||
|
}
|
||||||
|
replacements.update({k: env[k] for k in required})
|
||||||
|
replacements['PIHOLE_WEB_PORT'] = web_port
|
||||||
|
for key, value in replacements.items():
|
||||||
|
rendered = rendered.replace('${' + key + '}', value)
|
||||||
|
|
||||||
|
OUTPUT.write_text(rendered.rstrip() + '\n', encoding='utf-8')
|
||||||
|
print(f'Rendered {OUTPUT}')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
82
pihole/docker-compose.yaml
Normal file
82
pihole/docker-compose.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
services:
|
||||||
|
unbound:
|
||||||
|
image: mvance/unbound:latest
|
||||||
|
container_name: unbound-pihole-primary
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ}
|
||||||
|
UNBOUND_PORT: "5335"
|
||||||
|
volumes:
|
||||||
|
- ${APPDATA_UNBOUND_ROOT}:/opt/unbound/etc/unbound/
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "hex=$$(printf '%04X' \"$$UNBOUND_PORT\"); grep -qi \":$$hex\" /proc/net/tcp /proc/net/tcp6 || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 6
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
pihole:
|
||||||
|
image: pihole/pihole:latest
|
||||||
|
container_name: pihole-primary
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
unbound:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ}
|
||||||
|
PIHOLE_WEB_PORT: ${PD_PIHOLE_WEB_PORT}
|
||||||
|
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
|
||||||
|
FTLCONF_dns_listeningMode: "LOCAL"
|
||||||
|
FTLCONF_dns_upstreams: "127.0.0.1#5335"
|
||||||
|
FTLCONF_webserver_port: ${PD_PIHOLE_WEB_PORT}
|
||||||
|
FTLCONF_ntp_ipv4_active: "false"
|
||||||
|
FTLCONF_ntp_ipv6_active: "false"
|
||||||
|
FTLCONF_ntp_sync_active: "false"
|
||||||
|
volumes:
|
||||||
|
- ${APPDATA_PIHOLE_ROOT}/etc-pihole:/etc/pihole
|
||||||
|
- ${APPDATA_PIHOLE_ROOT}/etc-dnsmasq.d:/etc/dnsmasq.d
|
||||||
|
cap_add:
|
||||||
|
- SYS_NICE
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "hex=$$(printf '%04X' \"$$PIHOLE_WEB_PORT\"); grep -qi \":$$hex\" /proc/net/tcp /proc/net/tcp6 || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 6
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
keepalived:
|
||||||
|
image: osixia/keepalived:2.0.20
|
||||||
|
container_name: keepalived-pihole-primary
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pihole:
|
||||||
|
condition: service_healthy
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_BROADCAST
|
||||||
|
- NET_RAW
|
||||||
|
volumes:
|
||||||
|
- ./keepalived.conf:/usr/local/etc/keepalived/keepalived.conf:ro
|
||||||
|
|
||||||
|
nebula-sync:
|
||||||
|
image: lovelaze/nebula-sync:latest
|
||||||
|
container_name: nebula-sync
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pihole:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PRIMARY: ${NEBULA_SYNC_PRIMARY}
|
||||||
|
REPLICAS: ${NEBULA_SYNC_REPLICAS}
|
||||||
|
FULL_SYNC: ${NEBULA_SYNC_FULL_SYNC}
|
||||||
|
RUN_GRAVITY: ${NEBULA_SYNC_RUN_GRAVITY}
|
||||||
|
CRON: ${NEBULA_SYNC_CRON}
|
||||||
|
SYNC_GRAVITY_GROUP: ${NEBULA_SYNC_GRAVITY_GROUP}
|
||||||
|
SYNC_GRAVITY_AD_LIST: ${NEBULA_SYNC_GRAVITY_AD_LIST}
|
||||||
|
SYNC_GRAVITY_AD_LIST_BY_GROUP: ${NEBULA_SYNC_GRAVITY_AD_LIST_BY_GROUP}
|
||||||
|
SYNC_GRAVITY_DOMAIN_LIST: ${NEBULA_SYNC_GRAVITY_DOMAIN_LIST}
|
||||||
|
SYNC_GRAVITY_DOMAIN_LIST_BY_GROUP: ${NEBULA_SYNC_GRAVITY_DOMAIN_LIST_BY_GROUP}
|
||||||
32
pihole/keepalived.conf.template
Normal file
32
pihole/keepalived.conf.template
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
global_defs {
|
||||||
|
router_id ${ROUTER_ID_NAME}
|
||||||
|
}
|
||||||
|
|
||||||
|
vrrp_script chk_pihole_web {
|
||||||
|
script "grep -qi ':${PIHOLE_WEB_PORT_HEX}' /proc/net/tcp /proc/net/tcp6"
|
||||||
|
interval 2
|
||||||
|
fall 2
|
||||||
|
rise 2
|
||||||
|
}
|
||||||
|
|
||||||
|
vrrp_instance VI_DNS {
|
||||||
|
state ${KEEPALIVED_STATE}
|
||||||
|
interface ${LAN_INTERFACE}
|
||||||
|
virtual_router_id ${KEEPALIVED_ROUTER_ID}
|
||||||
|
priority ${KEEPALIVED_PRIORITY}
|
||||||
|
advert_int 1
|
||||||
|
${NOPREEMPT_LINE}
|
||||||
|
|
||||||
|
authentication {
|
||||||
|
auth_type PASS
|
||||||
|
auth_pass ${KEEPALIVED_PASSWORD}
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual_ipaddress {
|
||||||
|
${VIP_CIDR}
|
||||||
|
}
|
||||||
|
|
||||||
|
track_script {
|
||||||
|
chk_pihole_web
|
||||||
|
}
|
||||||
|
}
|
||||||
13
pihole/nomad/.env.example
Normal file
13
pihole/nomad/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
TZ=America/Chicago
|
||||||
|
APPDATA_PIHOLE_ROOT=/mnt/docker-ssd/docker/appdata/pihole-nomad
|
||||||
|
APPDATA_UNBOUND_ROOT=/mnt/docker-ssd/docker/appdata/unbound-nomad
|
||||||
|
PIHOLE_WEB_PORT=8954
|
||||||
|
NOMAD_PIHOLE_WEB_PORT=8954
|
||||||
|
PIHOLE_PASSWORD=CHANGE_ME
|
||||||
|
LAN_INTERFACE=enp5s0
|
||||||
|
VIP_CIDR=10.5.1.53/24
|
||||||
|
KEEPALIVED_ROUTER_ID=53
|
||||||
|
KEEPALIVED_STATE=BACKUP
|
||||||
|
KEEPALIVED_PRIORITY=90
|
||||||
|
KEEPALIVED_PASSWORD=CHANGE01
|
||||||
|
KEEPALIVED_NOPREEMPT=true
|
||||||
16
pihole/nomad/README.md
Normal file
16
pihole/nomad/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# NOMAD Pi-hole / Unbound / Keepalived Replica
|
||||||
|
|
||||||
|
- **Repo stack path:** `/home/fizzlepoof/repos/truenas-stacks/pihole/nomad`
|
||||||
|
- **Suggested live stack path:** `/mnt/docker-ssd/docker/compose/pihole-nomad`
|
||||||
|
- **Node role:** NOMAD backup / BACKUP1
|
||||||
|
- **VIP target:** `10.5.1.53/24`
|
||||||
|
- **LAN interface:** `enp5s0`
|
||||||
|
- **NOMAD LAN IP:** `10.5.1.16`
|
||||||
|
|
||||||
|
Bring-up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/docker-ssd/docker/compose/pihole-nomad
|
||||||
|
cp .env.example .env
|
||||||
|
bash bin/prepare_nomad.sh --up
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user