Paperless-AI sa postará o automatické tagovanie — ale čo ak chceš aktívne komunikovať so svojím archívom? Kedy vyprší záruka na práčku? Koľko som zaplatil za elektrinu minulý rok? Na tieto otázky ti odpovie Open WebUI prepojený cez Python bridge priamo s Paperless API.
A to nie je všetko — rovnaký Open WebUI inštanciu môžeš napojiť na router, AdGuard Home, Grafanu alebo akúkoľvek inú službu v homelabe, ktorá má API. Jeden chat interface, celý homelab pod kontrolou.
Čo všetko môžeš integrovať
Open WebUI podporuje viacero nástrojov naraz. Ja mám napojené:
Princíp je rovnaký pre všetky integrácie — jednoduchý Flask server, ktorý prekladá požiadavky od LLM na API volania príslušnej služby.
Krok 1 — Paperless API token
Prihlás sa do Paperless-ngx a klikni vpravo hore na svoje meno → My Profile → Auth Token. Token skopíruj — budeš ho potrebovať pri konfigurácii bridge.
Krok 2 — Štruktúra priečinkov
mkdir -p /volume1/docker/openwebui/bridges/paperless
Krok 3 — Paperless Bridge (Python Flask)
Bridge je Flask server — prijíma požiadavky od Open WebUI a preposiela ich do Paperless API. Vytvor súbor paperless_bridge.py v priečinku /volume1/docker/openwebui/bridges/paperless/:
import os, requests
from flask import Flask, jsonify, request
app = Flask(__name__)
PL_URL = os.environ.get("PAPERLESS_URL", "https://paperless.tvoja-domena.xyz")
PL_TOKEN = os.environ.get("PAPERLESS_TOKEN", "")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Token {PL_TOKEN}", "Content-Type": "application/json"})
def pl_get(path, params=None):
try:
r = SESSION.get(f"{PL_URL}/api/{path}", params=params, timeout=15)
r.raise_for_status()
return r.json(), 200
except Exception as e:
return {"error": str(e)}, 500
def pl_patch(path, data):
try:
r = SESSION.patch(f"{PL_URL}/api/{path}", json=data, timeout=15)
r.raise_for_status()
return r.json(), 200
except Exception as e:
return {"error": str(e)}, 500
def pl_post(path, json_data=None):
try:
r = SESSION.post(f"{PL_URL}/api/{path}", json=json_data, timeout=15)
r.raise_for_status()
try: return r.json(), 200
except: return {"success": True}, 200
except Exception as e:
return {"error": str(e)}, 500
@app.route("/health")
def health():
data, code = pl_get("documents/?page_size=1")
if code != 200: return jsonify({"status": "error", "detail": data}), 500
return jsonify({"status": "ok", "service": "paperless-bridge"})
@app.route("/stats")
def stats():
docs, _ = pl_get("documents/?page_size=1")
tags, _ = pl_get("tags/?page_size=1")
corr, _ = pl_get("correspondents/?page_size=1")
types, _ = pl_get("document_types/?page_size=1")
return jsonify({"documents": docs.get("count",0), "tags": tags.get("count",0),
"correspondents": corr.get("count",0), "document_types": types.get("count",0)})
@app.route("/documents")
def documents():
params = {"page": request.args.get("page",1), "page_size": request.args.get("page_size",25)}
query = request.args.get("query","")
if query: params["query"] = query
if request.args.get("tag_id"): params["tags__id__all"] = request.args.get("tag_id")
params["ordering"] = request.args.get("ordering","-created")
data, code = pl_get("documents/", params)
return jsonify(data), code
@app.route("/documents/untagged")
def untagged():
return jsonify(*pl_get("documents/", {"page_size": 50, "is_tagged": 0}))
@app.route("/documents/recent")
def recent():
return jsonify(*pl_get("documents/", {"page_size": request.args.get("limit",20), "ordering": "-added"}))
@app.route("/documents/<int:doc_id>")
def document_detail(doc_id):
return jsonify(*pl_get(f"documents/{doc_id}/"))
@app.route("/documents/<int:doc_id>/suggest")
def suggest(doc_id):
doc, code = pl_get(f"documents/{doc_id}/")
if code != 200: return jsonify(doc), code
tags_data, _ = pl_get("tags/?page_size=200")
corr_data, _ = pl_get("correspondents/?page_size=200")
types_data, _ = pl_get("document_types/?page_size=100")
return jsonify({"document": {"id": doc.get("id"), "title": doc.get("title"),
"content": doc.get("content","")[:3000], "current_tags": doc.get("tags",[]),
"current_correspondent": doc.get("correspondent"), "created": doc.get("created")},
"available_tags": tags_data.get("results",[]),
"available_correspondents": corr_data.get("results",[]),
"available_document_types": types_data.get("results",[])})
@app.route("/documents/<int:doc_id>/update", methods=["POST"])
def document_update(doc_id):
body = request.get_json(force=True, silent=True) or {}
return jsonify(*pl_patch(f"documents/{doc_id}/", body))
@app.route("/documents/bulk", methods=["POST"])
def documents_bulk():
body = request.get_json(force=True, silent=True) or {}
documents = body.get("documents",[])
method = body.get("method","")
parameters = body.get("parameters",{})
if not documents or not method:
return jsonify({"error": "Chýba documents alebo method"}), 400
return jsonify(*pl_post("documents/bulk_edit/", json_data={"documents":documents,"method":method,**parameters}))
@app.route("/tags")
def tags():
return jsonify(*pl_get("tags/?page_size=200"))
@app.route("/tags/create", methods=["POST"])
def tags_create():
body = request.get_json(force=True, silent=True) or {}
if not body.get("name"): return jsonify({"error": "Chýba name"}), 400
return jsonify(*pl_post("tags/", json_data={"name": body["name"]}))
@app.route("/correspondents")
def correspondents():
return jsonify(*pl_get("correspondents/?page_size=200"))
@app.route("/document_types")
def document_types():
return jsonify(*pl_get("document_types/?page_size=100"))
if __name__ == "__main__":
print(f"[Paperless Bridge] Start - {PL_URL}")
app.run(host="0.0.0.0", port=5003, debug=False)
Krok 4 — Docker Compose
Vytvor docker-compose.yml v /volume1/docker/openwebui/:
version: "3.8"
services:
openwebui:
image: ghcr.io/open-webui/open-webui:main
container_name: openwebui
restart: unless-stopped
network_mode: host
volumes:
- openwebui_data:/app/backend/data
environment:
- ANTHROPIC_API_KEY=<tvoj_claude_api_kluc>
- WEBUI_SECRET_KEY=<nahodny_retazec_min_32_znakov>
- WEBUI_AUTH=false
paperless-bridge:
image: python:3.11-slim
container_name: paperless-bridge
restart: unless-stopped
network_mode: host
environment:
- PAPERLESS_URL=https://paperless.tvoja-domena.xyz
- PAPERLESS_TOKEN=<tvoj_paperless_api_token>
volumes:
- /volume1/docker/openwebui/bridges/paperless:/app
working_dir: /app
command: >
sh -c "pip install flask requests --quiet &&
python paperless_bridge.py"
volumes:
openwebui_data:
driver: local
Oba kontajnery bežia priamo na sieťovom rozhraní NASu. Bridge tak vie dosiahnuť Paperless cez jeho FQDN alebo lokálnu IP bez problémov. Ak máš Paperless za Cloudflare Tunnelom, FQDN je najelegantnejšie riešenie.
Krok 5 — Spustenie a overenie
cd /volume1/docker/openwebui
sudo docker compose up -d
# Over ze bridge bezi
curl http://localhost:5003/health
# {"service":"paperless-bridge","status":"ok"}
curl http://localhost:5003/stats
# {"documents":501,"tags":94,...}
Kontajner paperless-bridge potrebuje paperless_bridge.py v namountovanom priečinku. Súbor vytvor pred spustením docker compose up, inak kontajner zlyhá.
Krok 6 — Open WebUI — pripojenie Claude API
Otvor http://<NAS_IP>:8080 v prehliadači.
- Vpravo hore → Settings → Admin → Connections
- Klikni + a pridaj: URL
https://api.anthropic.com/v1, API Key, typ autentifikácie Bearer - Ulož a v chate vyber model
claude-haiku-4-5-20251001aleboclaude-sonnet-4-6
Nechceš cloudové API? Open WebUI funguje aj s lokálnym Ollama — stačí pridať Ollama URL v nastaveniach. Claude je však výrazne lepší pri používaní nástrojov ako väčšina lokálnych modelov.
Krok 7 — Vytvorenie Paperless nástroja v Open WebUI
- Workspace → Tools → + (New Tool)
- Vlož kód nástroja (nižšie)
- Ulož a v chate aktivuj cez ikonu + vedľa vstupného poľa
"""
title: Paperless Control
description: Správa dokumentov v Paperless-ngx
version: 1.0.0
"""
import requests
PL = "http://localhost:5003"
class Tools:
def get_paperless_stats(self) -> str:
"""Vráti štatistiky Paperless — počet dokumentov, tagov, korešpondentov"""
r = requests.get(f"{PL}/stats", timeout=15)
return str(r.json())
def get_paperless_documents(self, query: str = "", page: int = 1) -> str:
"""Vráti zoznam dokumentov, voliteľne filtrovaných podľa textu"""
params = f"?page={page}&page_size=25"
if query: params += f"&query={query}"
r = requests.get(f"{PL}/documents{params}", timeout=15)
return str(r.json())
def get_paperless_untagged(self) -> str:
"""Vráti dokumenty bez tagov čakajúce na kategorizáciu"""
r = requests.get(f"{PL}/documents/untagged", timeout=15)
return str(r.json())
def get_document_for_categorization(self, doc_id: int) -> str:
"""Načíta dokument s obsahom a dostupnými tagmi pre AI kategorizáciu"""
r = requests.get(f"{PL}/documents/{doc_id}/suggest", timeout=15)
return str(r.json())
def update_paperless_document(self, doc_id: int, tags: list = None,
correspondent: int = None,
document_type: int = None,
title: str = None) -> str:
"""Aktualizuje dokument — tagy, korešpondent, typ alebo názov"""
body = {}
if tags is not None: body["tags"] = tags
if correspondent is not None: body["correspondent"] = correspondent
if document_type is not None: body["document_type"] = document_type
if title is not None: body["title"] = title
r = requests.post(f"{PL}/documents/{doc_id}/update", json=body, timeout=15)
return str(r.json())
def get_paperless_tags(self) -> str:
"""Vráti všetky existujúce tagy"""
r = requests.get(f"{PL}/tags", timeout=15)
return str(r.json())
def create_paperless_tag(self, name: str) -> str:
"""Vytvorí nový tag"""
r = requests.post(f"{PL}/tags/create", json={"name": name}, timeout=15)
return str(r.json())
def bulk_edit_documents(self, document_ids: list, method: str,
parameters: dict = None) -> str:
"""Hromadná editácia — set_tags, modify_tags, set_correspondent, delete"""
body = {"documents": document_ids, "method": method, "parameters": parameters or {}}
r = requests.post(f"{PL}/documents/bulk", json=body, timeout=30)
return str(r.json())
Test
Po aktivácii nástroja v chate vyskúšaj:
Ukáž štatistiky Paperless
Načítaj dokumenty bez tagov
Skategorizuj dokument č. 42 — načítaj obsah, navrhni tagy a počkaj na potvrdenie
Koľko dokumentov mám od roku 2024?
Ak localhost:5003 v Tools kóde nefunguje, použi LAN IP NASu (napr. 192.168.1.101:5003). Závisí od toho, ako Docker mapuje porty v host mode na tvojom systéme.