Compare commits

...

40 Commits

Author SHA1 Message Date
Fizzlepoof
4d78dd4524 Harden Paperless and school intake n8n workflows 2026-05-15 03:08:03 +00:00
Fizzlepoof
f7001d5130 Pin Paperless document stack component versions 2026-05-15 03:08:03 +00:00
Fizzlepoof
ff2e13a16f Align Kima Hub stack with live deployment 2026-05-15 03:08:03 +00:00
Fizzlepoof
5783963816 Standardize NOMAD host-run service paths 2026-05-15 03:08:03 +00:00
Fizzlepoof
837d6e5866 Add PD to Serenity backup automation scaffolding 2026-05-15 03:08:03 +00:00
Fizzlepoof
458989e6cd Add Doris KitchenOwl recipe importer 2026-05-15 03:03:58 +00:00
Fizzlepoof
79b546c70e feat(dashboard): rework paperless review actions 2026-05-14 19:43:57 +00:00
Fizzlepoof
1b61f7a669 fix(dashboard): make thumbs-down dismiss a single story 2026-05-14 19:36:47 +00:00
Fizzlepoof
88ea84b89a fix(dashboard): scope thumbs-up to a story and restore refreshes 2026-05-14 19:35:00 +00:00
Fizzlepoof
72724f8e2c docs(nomad): point to canonical doris dashboard path 2026-05-14 18:16:54 +00:00
Fizzlepoof
d7670fde4b feat(home): import doris dashboard from workspace repo
Migrates workspace-only dashboard work from commits 6f0c5cd, c851073, and d04eff3 into the canonical truenas-stacks repo.
2026-05-14 18:14:34 +00:00
Fizzlepoof
84b87baf2c docs(nomad): capture container ownership and recovery notes 2026-05-14 17:35:24 +00:00
Fizzlepoof
80842f95a7 docs(homelab): record pd static ip 2026-05-14 02:25:17 +00:00
Fizzlepoof
e567b97df8 refactor(n8n): consolidate school paperless enrichment 2026-05-14 02:18:40 +00:00
Fizzlepoof
bf5a138d1c docs(homelab): require secrets sync after env changes 2026-05-14 02:00:25 +00:00
Fizzlepoof
0a398eea9a fix(n8n): repair school intake upload flow 2026-05-14 01:56:07 +00:00
Fizzlepoof
a5c0c0b9ad feat(n8n): add school intake workflow v1.1 2026-05-13 21:32:18 +00:00
Fizzlepoof
f6482abcd6 docs(n8n): version school intake workflow artifacts 2026-05-13 21:09:43 +00:00
Fizzlepoof
3269cc1fc5 feat(n8n): add school paperless intake pipeline 2026-05-13 20:53:00 +00:00
Fizzlepoof
a04b2eecd7 fix(n8n): restore paperless webhook compatibility 2026-05-13 20:10:25 +00:00
Fizzlepoof
1b4838504b docs(gitea): document internal SSH remote
Record the working internal Gitea SSH remote as ssh://git@10.5.1.6:2222/<owner>/<repo>.git and replace stale HTTPS/gitea.paccoco.com examples for PD/NOMAD operations. Port 2222 is SSH, while the web UI remains on port 3002.
2026-05-13 17:15:39 +00:00
Fizzlepoof
cf5474369d docs(paperless): document Office ingestion support
Document Paperless Tika/Gotenberg sidecars, supported Office/OpenDocument ingestion formats, stack paths, validation/deploy procedure, and verification commands. Updates service inventory and architecture overview so PD docs match the deployed documents stack.
2026-05-13 17:06:52 +00:00
Fizzlepoof
5ed02928ec feat(paperless): add Tika and Gotenberg sidecars
Enable Paperless Office/OpenDocument ingestion by wiring internal Tika and Gotenberg services into the documents stack. Adds PAPERLESS_TIKA_* environment keys to .env.example and places both sidecars on an internal-only network with resource limits.
2026-05-13 17:06:47 +00:00
Fizzlepoof
7fe24d1956 feat: add paperless intake triage workflow 2026-05-13 03:38:31 +00:00
Fizzlepoof
30e01a9a09 docs: expand SECRETS_MANAGEMENT with full runbook and sync workflow 2026-05-12 08:30:12 -05:00
Fizzlepoof
e40fd48547 fix: sync-envs-to-secrets depth 3, fix ((copied++)) set -e bug 2026-05-12 08:22:40 -05:00
Fizzlepoof
faf92cfbf7 feat: add sync-envs-to-secrets.sh script 2026-05-12 08:20:00 -05:00
1a6a74a067 Merge branch 'main' of gitea.paccoco.com:fizzlepoof/truenas-stacks 2026-05-12 08:17:06 -05:00
Fizzlepoof
2e0bd6d49a feat: add git-crypt secrets repo setup and docs
- scripts/setup-secrets-repo.sh: replace dpkg-deb install with Docker method
- dev/docker-compose.yaml: add git-crypt-init service (profile: setup)
- docs/operations/SECRETS_MANAGEMENT.md: document git-crypt encrypted repo, unlock procedure, rebuild runbook, and TrueNAS install methods
2026-05-12 08:15:50 -05:00
Fizzlepoof
bc3722732a feat: add git-crypt secrets repo setup and docs
- scripts/setup-secrets-repo.sh: replace dpkg-deb install with Docker method
- dev/docker-compose.yaml: add git-crypt-init service (profile: setup)
- docs/operations/SECRETS_MANAGEMENT.md: document git-crypt encrypted repo,
  unlock procedure, rebuild runbook, and TrueNAS install methods
2026-05-12 08:06:31 -05:00
35077b1f7c Merge branch 'main' of github.com:Paccoco/truenas-stacks
# Conflicts:
#	rocinante/docker-compose.yaml
#	rocinante/watcher/watch.py
2026-05-10 09:57:48 -05:00
f3f1c346ac Local homelab changes 2026-05-10 09:56:29 -05:00
Paccoco
87dbd3e6ea Add OpenClaw to AI stack 2026-05-10 09:52:48 -05:00
Paccoco
96f1c69fd9 fix: chunk size limit + keep MP3s + Respond Accepted fix; add rocinante stack
- Chunk Text: word-level fallback for sentences > 600 chars
- watch.py: keep MP3 after transcription
- Respond Accepted: remove cross-node reference
- rocinante/: Whisper CUDA + watcher stack for RTX 4090 transcription
2026-05-09 21:10:23 -05:00
Paccoco
9aef43ee10 workflow 08: v1.1 fixes + docs update
- 08-class-recording-rag-v1.1.json: Whisper URL → 10.5.1.16:8786,
  model → faster-distil-whisper-small.en, auth headers → env vars,
  inputDataFieldName fix, Paperless response format text
- PLAUSIBLEDENABILITY.md: remove Whisper from GPU list (moved to N.O.M.A.D.)
- n8n-workflows/README.md: fix class recordings path to /mnt/tank,
  correct workflow 08 trigger (webhook, not folder watcher),
  add curl upload example and docker-compose setup instructions
2026-05-09 19:07:27 -05:00
Paccoco
11904f39b2 Workflow 16 NFS Watchdog v1.2 working; fix mount points, container names, sudo path 2026-05-09 16:04:44 -05:00
Paccoco
35019e08cc Test git webhook trigger 2026-05-09 15:24:56 -05:00
Paccoco
ca3de65e34 Add workflow 01 v1.4; update README env var docs and TODO 2026-05-09 15:21:11 -05:00
Paccoco
6df8f80d24 Workflow 01 v1.4 tested and working; update docs, TODO, and automation compose 2026-05-09 15:16:30 -05:00
Paccoco
8e8b54d176 Version all workflows at v1.0 2026-05-09 13:51:58 -05:00
96 changed files with 16632 additions and 175 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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
View 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.

View 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
View 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)"
}

View 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"

View 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"

View 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."

View File

@@ -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
View File

View 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

View File

@@ -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`)

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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)

View 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.

View 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'
```

View 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.

View 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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View 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`)

View File

@@ -1,27 +1,27 @@
# 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 + Serenity + RPi4) — see docs/planning/DEPLOY_PIHOLE.md
- 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 +32,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 4249 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 +64,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

View File

@@ -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)

View 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);

View File

@@ -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 audits 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

View File

@@ -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 |
|-------|------|---------| |-------|------|---------|

View File

@@ -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

View File

@@ -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
View 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.

View File

@@ -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
View 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

View File

@@ -0,0 +1,255 @@
# Doris Dashboard
Local static homepage for Johns 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 NOMADs 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.

View 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

View 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()

View 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}'
'&current=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 storys 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 — Ill look for more <strong>${topic}</strong> like this, and Ive 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())

View File

@@ -0,0 +1 @@
[]

View 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"
}
]

View 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"
}
]

View 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

View 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}}

View File

@@ -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
View File

0
meshtastic/scripts/mm_wx.py Executable file → Normal file
View File

6
monitoring/.env.example Normal file
View 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
View 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
View 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"

View 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": ""
}
}

View 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": ""
}
}

View 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": ""
}
}

View 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": ""
}
}

View 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": {}
}

View 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": ""
}
}

View 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": ""
}
}

View File

@@ -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": {

View 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": ""
}
}

View 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" }
]
}

View 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": ""
}
}

File diff suppressed because one or more lines are too long

View 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": ""
}
}

View 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": ""
}
}

View 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": ""
}
}

View 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": ""
}
}

View 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" }
]
}

View 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"
}
]
}

View 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"
}
]
}

View 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"
}

View 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"
}

View 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"
}
]
}

View 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"
}

View 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"
}

View 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"
}
]
}

View 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"
}

View 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" }
]
}

View 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"
}
]
}

View 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"
}
]
}

View 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"
}
]
}

View 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"
}
]
}

View 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"
}
]
}

View 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": []
}

View 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": []
}

View File

@@ -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.

View 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`.

View File

@@ -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."
}

View File

@@ -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"
}

View File

@@ -0,0 +1,3 @@
{
"document_id": 12345
}

View File

@@ -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"

2
rocinante/.env.example Normal file
View File

@@ -0,0 +1,2 @@
TZ=America/Chicago
N8N_WEBHOOK_URL=https://n8n.paccoco.com/webhook/class/ingest

View File

@@ -0,0 +1,42 @@
name: rocinante
services:
whisper:
image: fedirz/faster-whisper-server:latest-cuda
container_name: rocinante-whisper
restart: unless-stopped
ports:
- "8787:8000"
environment:
WHISPER__MODEL: large-v3
WHISPER__INFERENCE_DEVICE: cuda
WHISPER__VAD_FILTER: "false"
WHISPER__CONDITION_ON_PREVIOUS_TEXT: "false"
TZ: ${TZ}
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
- whisper-cache:/root/.cache/huggingface
watcher:
build: ./watcher
container_name: rocinante-watcher
restart: unless-stopped
environment:
WHISPER_URL: http://whisper:8000
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
WATCH_DIR: /recordings
TZ: ${TZ}
volumes:
- C:/Recordings:/recordings
depends_on:
- whisper
volumes:
whisper-cache:

View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir watchdog requests
COPY watch.py /app/watch.py
WORKDIR /app
CMD ["python", "-u", "watch.py"]

360
rocinante/watcher/watch.py Normal file
View File

@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""
Class recording watcher for Rocinante.
Watches C:/Recordings (mounted as /recordings) for new .wav files.
For each file detected:
1. Waits for the file to finish being written
2. Converts WAV -> MP3 via ffmpeg (deletes original WAV)
3. Splits long recordings into 10-minute chunks
4. Transcribes each chunk via local faster-whisper-server (large-v3, CUDA)
5. Stitches results back together with corrected timestamps
6. POSTs transcript + metadata to n8n /webhook/class/ingest
Folder convention: subfolder name = class name
/recordings/HIST-2020/lecture-03-3.wav -> class=HIST-2020, title=lecture 03 3
"""
import os
import time
import subprocess
import logging
import requests
from pathlib import Path
from watchdog.observers.polling import PollingObserver as Observer
from watchdog.events import FileSystemEventHandler
# Config from environment
WHISPER_URL = os.environ.get("WHISPER_URL", "http://whisper:8000")
N8N_WEBHOOK = os.environ.get("N8N_WEBHOOK_URL", "https://n8n.paccoco.com/webhook/class/ingest")
WATCH_DIR = os.environ.get("WATCH_DIR", "/recordings")
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "large-v3")
CHUNK_MINUTES = int(os.environ.get("CHUNK_MINUTES", "10"))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("watcher")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def wait_for_file_stable(path: Path, interval: float = 2.0, required_stable: int = 3) -> None:
"""Block until the file size stops changing (i.e. the copy is complete)."""
log.info(f"Waiting for {path.name} to finish writing...")
prev_size = -1
stable_count = 0
while stable_count < required_stable:
try:
size = path.stat().st_size
except FileNotFoundError:
time.sleep(interval)
continue
if size == prev_size and size > 0:
stable_count += 1
else:
stable_count = 0
prev_size = size
time.sleep(interval)
log.info(f"{path.name} is stable ({prev_size:,} bytes)")
# Extra check: verify the file handle is released and readable
for attempt in range(60):
try:
with open(path, 'rb') as f:
f.read(1024)
break
except (PermissionError, OSError) as e:
log.info(f"File not yet readable ({e}), retrying... ({attempt + 1}/60)")
time.sleep(5)
else:
raise RuntimeError(f"File {path.name} is not readable after 60 attempts")
def convert_to_mp3(wav_path: Path) -> Path:
mp3_path = wav_path.with_suffix(".mp3")
log.info(f"Converting to MP3: {wav_path.name}")
result = subprocess.run(
[
"ffmpeg", "-y",
"-i", str(wav_path),
"-codec:a", "libmp3lame",
"-qscale:a", "4",
str(mp3_path),
],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed:\n{result.stderr}")
wav_path.unlink()
log.info(f"Deleted original WAV: {wav_path.name}")
return mp3_path
def get_duration(mp3_path: Path) -> float:
"""Return audio duration in seconds via ffprobe."""
result = subprocess.run(
[
"ffprobe", "-v", "quiet",
"-show_entries", "format=duration",
"-of", "csv=p=0",
str(mp3_path),
],
capture_output=True,
text=True,
)
return float(result.stdout.strip() or "0")
def split_into_chunks(mp3_path: Path, chunk_minutes: int = CHUNK_MINUTES) -> list:
"""Split MP3 into chunks. Returns [mp3_path] if short enough."""
duration = get_duration(mp3_path)
chunk_secs = chunk_minutes * 60
if duration <= chunk_secs:
log.info(f"Recording is {duration/60:.1f}min — no splitting needed")
return [mp3_path]
total_chunks = int(duration / chunk_secs) + 1
log.info(f"Recording is {duration/60:.1f}min — splitting into {total_chunks} x {chunk_minutes}min chunks")
chunks = []
start = 0.0
i = 0
while start < duration:
chunk_path = mp3_path.parent / f"{mp3_path.stem}_chunk{i:03d}.mp3"
subprocess.run(
[
"ffmpeg", "-y",
"-i", str(mp3_path),
"-ss", str(start),
"-t", str(chunk_secs),
"-codec:a", "copy",
str(chunk_path),
],
capture_output=True,
)
chunks.append((chunk_path, start))
start += chunk_secs
i += 1
return chunks
def transcribe_chunk(mp3_path: Path) -> dict:
with open(mp3_path, "rb") as f:
resp = requests.post(
f"{WHISPER_URL}/v1/audio/transcriptions",
files={"file": (mp3_path.name, f, "audio/mpeg")},
data={
"model": WHISPER_MODEL,
"language": "en",
"response_format": "verbose_json",
},
timeout=3600,
)
resp.raise_for_status()
return resp.json()
def transcribe(mp3_path: Path) -> dict:
"""Transcribe an MP3, splitting into chunks if needed."""
chunks = split_into_chunks(mp3_path)
# Short recording — single chunk, no splitting
if len(chunks) == 1 and isinstance(chunks[0], Path):
log.info(f"Transcribing {mp3_path.name} with {WHISPER_MODEL}...")
result = transcribe_chunk(mp3_path)
result["segments"] = deduplicate_hallucinations(result.get("segments", []))
log.info("Transcription complete")
return result
# Long recording — transcribe each chunk and stitch
all_segments = []
all_text = []
for i, (chunk_path, time_offset) in enumerate(chunks):
log.info(f"Transcribing chunk {i+1}/{len(chunks)}: {chunk_path.name} (offset {time_offset/60:.1f}min)")
try:
result = transcribe_chunk(chunk_path)
segments = result.get("segments", [])
for s in segments:
s["start"] = s.get("start", 0) + time_offset
s["end"] = s.get("end", 0) + time_offset
all_segments.extend(segments)
all_text.append(result.get("text", "").strip())
except Exception as e:
log.error(f"Chunk {i+1} failed: {e}")
finally:
chunk_path.unlink(missing_ok=True)
all_segments = deduplicate_hallucinations(all_segments)
total_duration = get_duration(mp3_path)
log.info(f"Transcription complete — {len(chunks)} chunks stitched")
return {
"text": " ".join(all_text),
"segments": all_segments,
"duration": total_duration,
}
def is_silence(text: str) -> bool:
"""Return True if the segment is just silence/noise markers."""
stripped = text.strip()
return not stripped or all(c in '.…♪ \t' for c in stripped)
def deduplicate_hallucinations(segments: list, max_repeats: int = 3) -> list:
"""
Remove Whisper phrase-loop hallucinations.
When Whisper gets stuck it emits the same segment text hundreds of times
in a row (e.g. "I'm also a software engineer." × 200). This function
collapses any run longer than `max_repeats` down to at most `max_repeats`
occurrences and logs a warning so the truncation is visible in the logs.
"""
if not segments:
return segments
result = []
i = 0
while i < len(segments):
text = segments[i].get("text", "").strip().lower()
j = i + 1
while j < len(segments) and segments[j].get("text", "").strip().lower() == text:
j += 1
count = j - i
if count > max_repeats:
log.warning(
f"Hallucination detected: '{segments[i].get('text', '').strip()[:60]}'"
f" × {count} — keeping {max_repeats}, dropping {count - max_repeats}"
)
result.extend(segments[i : i + max_repeats])
else:
result.extend(segments[i:j])
i = j
return result
def build_timestamped(segments: list) -> str:
lines = []
for s in segments:
text = s.get('text', '').strip()
if is_silence(text):
continue
mins = int(s.get("start", 0) // 60)
secs = int(s.get("start", 0) % 60)
lines.append(f"[{mins}:{secs:02d}] {text}")
return "\n".join(lines)
def send_to_n8n(class_name: str, lecture_title: str, result: dict) -> None:
segments = result.get("segments", [])
# Filter silence from full text
meaningful = [s.get("text", "") for s in segments if not is_silence(s.get("text", ""))]
full_text = " ".join(meaningful).strip() or result.get("text", "").strip()
duration = result.get("duration") or (segments[-1].get("end", 0) if segments else 0)
payload = {
"class_name": class_name,
"lecture_title": lecture_title,
"transcript": full_text,
"timestamped_transcript": build_timestamped(segments),
"duration_seconds": duration,
}
log.info(f"Sending to n8n: {class_name} / {lecture_title} ({len(full_text)} chars)")
resp = requests.post(N8N_WEBHOOK, json=payload, timeout=300)
resp.raise_for_status()
log.info(f"n8n accepted — HTTP {resp.status_code}")
# ---------------------------------------------------------------------------
# Processing pipeline
# ---------------------------------------------------------------------------
def process_wav(wav_path: Path) -> None:
# Must be inside a class subfolder, not the root recordings dir
if wav_path.parent == Path(WATCH_DIR):
log.warning(f"Skipping {wav_path.name} — drop files into a class subfolder, e.g. /recordings/HIST-2020/")
return
class_name = wav_path.parent.name
lecture_title = wav_path.stem.replace("-", " ").replace("_", " ")
log.info(f"--- Processing: {class_name} / {lecture_title} ---")
wait_for_file_stable(wav_path)
mp3_path = convert_to_mp3(wav_path)
result = transcribe(mp3_path)
send_to_n8n(class_name, lecture_title, result)
log.info(f"--- Done: {class_name} / {lecture_title} ---")
# ---------------------------------------------------------------------------
# Watchdog handler
# ---------------------------------------------------------------------------
class WavHandler(FileSystemEventHandler):
def on_created(self, event):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix.lower() == ".wav":
log.info(f"Detected new WAV: {path}")
try:
process_wav(path)
except Exception as exc:
log.error(f"Failed to process {path}: {exc}", exc_info=True)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def scan_existing(watch_dir: Path) -> None:
"""Process any .wav files already present at startup."""
existing = list(watch_dir.rglob("*.wav")) + list(watch_dir.rglob("*.WAV"))
if not existing:
return
log.info(f"Found {len(existing)} existing WAV(s) at startup — processing...")
for wav in existing:
try:
process_wav(wav)
except Exception as exc:
log.error(f"Failed to process {wav}: {exc}", exc_info=True)
if __name__ == "__main__":
watch_dir = Path(WATCH_DIR)
watch_dir.mkdir(parents=True, exist_ok=True)
log.info(f"Whisper URL : {WHISPER_URL}")
log.info(f"n8n webhook : {N8N_WEBHOOK}")
log.info(f"Watching : {watch_dir} (recursive)")
# Process any WAVs already sitting in the folder
scan_existing(watch_dir)
observer = Observer()
observer.schedule(WavHandler(), str(watch_dir), recursive=True)
observer.start()
log.info("Watcher started — drop a .wav into a class subfolder to begin")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
observer.stop()
observer.join()
log.info("Watcher stopped")

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# setup-secrets-repo.sh
# One-shot: creates a private encrypted secrets repo on Gitea using git-crypt.
# Run as root on PD.
set -euo pipefail
# ── CONFIG ────────────────────────────────────────────────────────────────────
GITEA_HTTP_URL="http://10.5.1.6:3000" # ← local Gitea HTTP port (check TrueNAS app config)
GITEA_SSH_PORT="2222" # ← Gitea SSH port
GITEA_USER="fizzlepoof"
GITEA_TOKEN="aa711a194bd30f6355a0f5d396520d08762483aa" # ← regenerate with write:repository + write:user scopes
REPO_NAME="homelab-secrets"
LOCAL_PATH="/mnt/docker-ssd/docker/secrets"
KEY_EXPORT_PATH="/root/.git-crypt-secrets.key" # back this up somewhere safe!
# ─────────────────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[✗]${NC} $*" >&2; exit 1; }
[[ -z "$GITEA_TOKEN" ]] && die "GITEA_TOKEN is not set. Edit the CONFIG section and re-run."
# ── 1. Dependencies ───────────────────────────────────────────────────────────
info "Checking dependencies..."
command -v git &>/dev/null || die "git is not installed"
command -v curl &>/dev/null || die "curl is not installed"
if ! command -v git-crypt &>/dev/null; then
info "git-crypt not found — installing via Docker (apt is blocked on TrueNAS)..."
command -v docker &>/dev/null || die "docker is not installed"
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" \
|| die "Docker-based git-crypt install failed"
mkdir -p /root/bin
cp /tmp/git-crypt /root/bin/git-crypt
chmod +x /root/bin/git-crypt
export PATH="/root/bin:$PATH"
info "git-crypt installed to /root/bin/git-crypt"
fi
# ── 2. Create private repo on Gitea ──────────────────────────────────────────
info "Creating private repo '${REPO_NAME}' on Gitea..."
HTTP_CODE=$(curl -s -o /tmp/gitea-create.json -w "%{http_code}" \
-X POST "${GITEA_HTTP_URL}/api/v1/user/repos" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"${REPO_NAME}\",
\"description\": \"Homelab secrets — git-crypt encrypted\",
\"private\": true,
\"auto_init\": false
}")
if [[ "$HTTP_CODE" == "201" ]]; then
info "Repo created successfully."
elif [[ "$HTTP_CODE" == "409" ]]; then
warn "Repo already exists on Gitea — continuing."
else
cat /tmp/gitea-create.json
die "Gitea API returned HTTP ${HTTP_CODE}. Check token and URL."
fi
REPO_URL="ssh://git@10.5.1.6:${GITEA_SSH_PORT}/${GITEA_USER}/${REPO_NAME}.git"
# ── 3. Init local repo ────────────────────────────────────────────────────────
info "Setting up local repo at ${LOCAL_PATH}..."
mkdir -p "$LOCAL_PATH"
cd "$LOCAL_PATH"
if [[ ! -d .git ]]; then
git init
git remote add origin "$REPO_URL"
else
warn ".git already exists — skipping init."
git remote set-url origin "$REPO_URL" 2>/dev/null || true
fi
# ── 4. Init git-crypt ─────────────────────────────────────────────────────────
if [[ ! -d .git-crypt ]]; then
info "Initialising git-crypt..."
git-crypt init
else
warn "git-crypt already initialised — skipping."
fi
# ── 5. Export symmetric key ───────────────────────────────────────────────────
info "Exporting symmetric key to ${KEY_EXPORT_PATH}..."
git-crypt export-key "$KEY_EXPORT_PATH"
chmod 600 "$KEY_EXPORT_PATH"
warn "BACK THIS KEY UP. Without it you cannot decrypt the repo on another machine."
warn " scp root@10.5.1.6:${KEY_EXPORT_PATH} /safe/location/"
# ── 6. .gitattributes — encrypt everything except meta files ─────────────────
info "Writing .gitattributes..."
cat > .gitattributes << 'EOF'
# Encrypt everything by default
* filter=git-crypt diff=git-crypt
# These stay plaintext
.gitattributes !filter !diff
README.md !filter !diff
EOF
# ── 7. Directory structure ────────────────────────────────────────────────────
info "Creating directory structure..."
mkdir -p env keys certs tokens ssh
cat > README.md << EOF
# homelab-secrets
Private secrets store for the homelab. All files except this README and
.gitattributes are encrypted at rest with git-crypt.
## Unlock on a new machine
\`\`\`bash
git clone ${REPO_URL}
cd ${REPO_NAME}
git-crypt unlock /path/to/.git-crypt-secrets.key
\`\`\`
## Structure
| Directory | Contents |
|-----------|----------|
| \`env/\` | .env files for each stack |
| \`keys/\` | API keys and tokens |
| \`certs/\` | TLS certificates |
| \`tokens/\`| Service tokens (Paperless, LiteLLM, etc.) |
| \`ssh/\` | SSH private keys |
## Key location
The symmetric key is stored at \`${KEY_EXPORT_PATH}\` on PD.
Back it up to a password manager or USB drive.
EOF
# Placeholder files so dirs aren't empty
for dir in env keys certs tokens ssh; do
touch "${dir}/.gitkeep"
done
# ── 8. Initial commit and push ────────────────────────────────────────────────
info "Committing and pushing..."
git add -A
git -c user.name="Fizzlepoof" -c user.email="admin@paccoco.com" \
commit -m "chore: init encrypted secrets repo"
git push -u origin main 2>/dev/null || git push -u origin master 2>/dev/null || \
die "Push failed. Check that Gitea credentials are configured (git credential store)."
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN} Done!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo " Repo: ${REPO_URL}"
echo " Local: ${LOCAL_PATH}"
echo " Key: ${KEY_EXPORT_PATH}"
echo ""
echo -e "${YELLOW} Next: back up the key, then start dropping .env files into ${LOCAL_PATH}/env/${NC}"
echo ""

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# sync-envs-to-secrets.sh
# Copies all .env files from compose stack folders into the git-crypt secrets repo.
# Run as root on PD. Safe to re-run — overwrites stale copies.
set -euo pipefail
COMPOSE_ROOT="/mnt/docker-ssd/docker/compose"
SECRETS_REPO="/mnt/docker-ssd/docker/secrets"
ENV_DIR="${SECRETS_REPO}/env"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[✗]${NC} $*" >&2; exit 1; }
[[ -d "$SECRETS_REPO/.git" ]] || die "Secrets repo not found at ${SECRETS_REPO}. Run setup-secrets-repo.sh first."
[[ -d "$COMPOSE_ROOT" ]] || die "Compose root not found at ${COMPOSE_ROOT}."
# Ensure git-crypt is unlocked — encrypted files show as binary if not
if file "${ENV_DIR}"/*.env &>/dev/null 2>&1 && file "${ENV_DIR}"/*.env 2>/dev/null | grep -q "Git crypt"; then
die "Secrets repo appears to be locked. Run: git-crypt unlock /root/.git-crypt-secrets.key"
fi
info "Scanning ${COMPOSE_ROOT} for .env files..."
copied=0
skipped=0
while IFS= read -r -d '' envfile; do
# Derive stack name: use "parent-child" for nested dirs (e.g. media/kima-hub → media-kima-hub)
relpath="${envfile#${COMPOSE_ROOT}/}" # e.g. media/kima-hub/.env
stackpath="${relpath%/.env}" # e.g. media/kima-hub
stack="${stackpath//\//-}" # e.g. media-kima-hub
dest="${ENV_DIR}/${stack}.env"
if [[ -f "$dest" ]]; then
if ! diff -q "$envfile" "$dest" &>/dev/null; then
cp "$envfile" "$dest"
info "Updated: ${stack}.env"
copied=$((copied + 1))
else
warn "Unchanged: ${stack}.env — skipping"
skipped=$((skipped + 1))
fi
else
cp "$envfile" "$dest"
info "Added: ${stack}.env"
copied=$((copied + 1))
fi
done < <(find "$COMPOSE_ROOT" -maxdepth 3 -name ".env" -not -path "*/.git/*" -print0 | sort -z)
if [[ $copied -eq 0 ]]; then
warn "Nothing new to commit ($skipped unchanged)."
exit 0
fi
info "Committing ${copied} file(s) to secrets repo..."
cd "$SECRETS_REPO"
git add env/
git -c user.name="Fizzlepoof" -c user.email="admin@paccoco.com" \
commit -m "chore: sync .env files from compose stacks
$(find "${ENV_DIR}" -name "*.env" -newer "${SECRETS_REPO}/.git/COMMIT_EDITMSG" 2>/dev/null \
| sort | xargs -I{} basename {} | sed 's/^/ - /' || echo " (see diff)")"
git push
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN} Done! ${copied} env file(s) synced, ${skipped} unchanged.${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo " Files in secrets repo:"
ls "${ENV_DIR}"/*.env 2>/dev/null | while read -r f; do echo " $(basename "$f")"; done
echo ""