From 064df91303d926d85c538a2ede1f165c4ea64bf7 Mon Sep 17 00:00:00 2001 From: Julius Wolff Date: Fri, 13 Mar 2026 18:36:56 +0100 Subject: [PATCH] Initial Commit of Local files --- .env | 21 ++++++++ Dockerfile | 18 +++++++ README.md | 125 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 68 +++++++++++++++++++++++ init_ollama.sh | 21 ++++++++ requirements.txt | 12 +++++ src/app.py | 67 +++++++++++++++++++++++ src/database.py | 54 +++++++++++++++++++ src/ingest_job.py | 93 ++++++++++++++++++++++++++++++++ src/rag_core.py | 45 ++++++++++++++++ src/run_scheduler.py | 20 +++++++ src/telegram_bot.py | 55 +++++++++++++++++++ 12 files changed, 599 insertions(+) create mode 100644 .env create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100755 init_ollama.sh create mode 100644 requirements.txt create mode 100644 src/app.py create mode 100644 src/database.py create mode 100644 src/ingest_job.py create mode 100644 src/rag_core.py create mode 100644 src/run_scheduler.py create mode 100644 src/telegram_bot.py diff --git a/.env b/.env new file mode 100644 index 0000000..effb853 --- /dev/null +++ b/.env @@ -0,0 +1,21 @@ +# Paperless +PAPERLESS_URL=http://your-paperless-ip:8000 +PAPERLESS_TOKEN=your_api_token_here + +# Postgres +POSTGRES_USER=raguser +POSTGRES_PASSWORD=ragpass +POSTGRES_DB=ragdb +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 + +# Chroma +CHROMA_HOST=chromadb +CHROMA_PORT=8000 + +# Ollama +OLLAMA_BASE_URL=http://ollama:11434 + +# Telegram +TELEGRAM_BOT_TOKEN=your_telegram_bot_token +ALLOWED_TELEGRAM_USERS=123456789,987654321 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf86a7c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +# System dependencies für unstructured (PDFs, Tabellen, OCR) +RUN apt-get update && apt-get install -y \ + poppler-utils \ + tesseract-ocr \ + tesseract-ocr-deu \ + libmagic-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +ENV PYTHONPATH=/app \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..efc0447 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# DocuMind-G4 + +Dieses Projekt implementiert ein lokales Retrieval-Augmented Generation (RAG) System, das Dokumente aus **Paperless-ngx** synchronisiert, verarbeitet und über eine **Streamlit** Web-UI sowie einen **Telegram Bot** durchsuchbar macht. + + + +## Features + +* **Delta-Sync mit Paperless-ngx**: Synchronisiert nur neue oder geänderte Dokumente via API und löscht entfernte Dokumente automatisch aus dem Vektor-Speicher. +* **Hi-Res PDF Parsing**: Nutzt `unstructured` mit OCR (Tesseract), um Tabellen und komplexe Layouts in PDFs korrekt zu erfassen. +* **Parent-Child Retrieval**: Splittet Dokumente intelligent auf (kleine Chunks für die Vektorsuche in ChromaDB, große Chunks/ganze Dokumente für den LLM-Kontext in PostgreSQL/DocStore), um den Kontextverlust zu minimieren. +* **Lokales LLM**: Verwendet `granite4:tiny-h` über **Ollama** für maximale Daten-Privatsphäre. +* **Multi-Interface**: Bietet eine Web-Oberfläche (Streamlit) mit Metadaten-Filterung (z.B. nach Document ID) und einen zugangsbeschränkten Telegram-Bot. +* **Vollständig Dockerisiert**: Alle Komponenten (PostgreSQL, ChromaDB, Ollama, Scheduler, Streamlit, Telegram) laufen isoliert in Containern. + +--- + +## Voraussetzungen + +* **Docker** und **Docker Compose** müssen installiert sein. +* Eine laufende **Paperless-ngx** Instanz. +* Ein **Paperless API Token** (kann im Paperless-Admin-Bereich erstellt werden). +* Ein **Telegram Bot Token** (über den BotFather in Telegram erstellbar) sowie deine Telegram User-ID. + +--- + +## Setup & Installation + +**1. Repository vorbereiten** +Stelle sicher, dass alle Dateien (`docker-compose.yml`, `Dockerfile`, `requirements.txt`, `init_ollama.sh` und der `src/`-Ordner) korrekt am selben Ort liegen. + +**2. Skript ausführbar machen (Linux/macOS)** +Das Ollama-Startskript benötigt Ausführungsrechte: +```bash +chmod +x init_ollama.sh +``` +**3. Umgebungsvariablen konfigurieren** +Erstelle eine .env Datei im Hauptverzeichnis und fülle sie mit deinen Daten: + +``` +# Paperless +PAPERLESS_URL=http://:8000 +PAPERLESS_TOKEN= + +# Postgres (Standardwerte können belassen werden) +POSTGRES_USER=raguser +POSTGRES_PASSWORD=ragpass +POSTGRES_DB=ragdb +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 + +# Chroma & Ollama (Interne Docker-Routings) +CHROMA_HOST=chromadb +CHROMA_PORT=8000 +OLLAMA_BASE_URL=http://ollama:11434 + +# Telegram +TELEGRAM_BOT_TOKEN= +ALLOWED_TELEGRAM_USERS= +``` + +## Starten & Ausführen + +Starte das gesamte System im Hintergrund: +```bash +docker-compose up -d --build +``` + +**Was passiert beim ersten Start?** + + * Die Datenbanken (Postgres, Chroma) werden initialisiert. + + * Der ollama-Container startet, wartet kurz und lädt automatisch das Modell granite4:tiny-h herunter. + + * Der scheduler wartet auf 03:00 Uhr nachts für den initialen Ingest (siehe Troubleshooting für einen manuellen Start). + + * streamlit und der telegram-bot gehen online. + +**Zugriff:** + + * Streamlit Web-UI: Öffne http://localhost:8501 in deinem Browser. + + * Telegram Bot: Suche deinen Bot in Telegram und sende /start. + +--------------------------------------------------------------------------------------------------------------------------- + + ## Nützliche Docker-Befehle (Troubleshooting & Logs) + +Da das System aus vielen Microservices besteht, ist es wichtig zu wissen, wie man die Logs der einzelnen Container ausliest. + +**Logs des nächtlichen Schedulers ansehen:** +Hier siehst du, ob neue Dokumente aus Paperless geladen wurden oder ob Fehler beim PDF-Parsing (OCR) auftraten. +```bash +docker-compose logs -f scheduler +``` + +**Logs des Telegram-Bots ansehen:** +Hilfreich, wenn der Bot nicht antwortet oder User-IDs abgewiesen werden. +```bash +docker-compose logs -f telegram-bot +``` + +**Ollama Status prüfen:** +Sieh nach, ob das Modell erfolgreich heruntergeladen wurde. +```bash +docker-compose logs -f ollama +``` + +**Manuellen Ingest (Sync) sofort anstoßen:** +Falls du nicht bis 03:00 Uhr nachts warten willst, kannst du den Sync-Job manuell im laufenden Scheduler-Container ausführen: +```bash +docker exec -it python src/ingest_job.py +``` +(Den genauen Containernamen findest du mit docker ps heraus). + +**System komplett stoppen und Daten behalten:** +```bash +docker-compose down +``` + +**System stoppen und ALLE Daten (Vektoren, DB, Modelle) löschen: +Achtung: Dies löscht alle Volumes unwiderruflich.** +```bash +docker-compose down -v +``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4480f1a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +version: '3.8' + +services: + postgres: + image: postgres:15 + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + chromadb: + image: chromadb/chroma:latest + volumes: + - chroma_data:/chroma/chroma + ports: + - "8000:8000" + + ollama: + image: ollama/ollama:latest + volumes: + - ollama_data:/root/.ollama + # Binde das lokale Skript in den Container ein + - ./init_ollama.sh:/init_ollama.sh + # Setze das Skript als Startbefehl und stelle sicher, dass es mit bash ausgeführt wird + entrypoint: ["/usr/bin/env", "bash", "/init_ollama.sh"] + ports: + - "11434:11434" + + # Container für Streamlit UI + streamlit: + build: . + command: streamlit run src/app.py --server.port=8501 --server.address=0.0.0.0 + ports: + - "8501:8501" + env_file: .env + depends_on: + - postgres + - chromadb + - ollama + + # Container für den Telegram Bot + telegram-bot: + build: . + command: python src/telegram_bot.py + env_file: .env + depends_on: + - postgres + - chromadb + - ollama + + # Container für den Ingest-Scheduler + scheduler: + build: . + command: python src/run_scheduler.py + env_file: .env + depends_on: + - postgres + - chromadb + - ollama + +volumes: + postgres_data: + chroma_data: + ollama_data: \ No newline at end of file diff --git a/init_ollama.sh b/init_ollama.sh new file mode 100755 index 0000000..c6e8ce4 --- /dev/null +++ b/init_ollama.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# 1. Starte den Ollama-Server im Hintergrund +/bin/ollama serve & +OLLAMA_PID=$! + +# 2. Warte, bis die API erreichbar ist +echo "Warte darauf, dass der Ollama-Server hochfährt..." +while ! curl -s http://localhost:11434/api/tags > /dev/null; do + sleep 2 +done + +echo "Ollama ist erreichbar! Prüfe/Lade das Modell 'granite4:tiny-h'..." + +# 3. Lade das Modell herunter (falls noch nicht vorhanden) +ollama pull granite4:tiny-h + +echo "Modell ist einsatzbereit!" + +# 4. Halte den Container am Laufen, indem der Ollama-Prozess im Vordergrund gehalten wird +wait $OLLAMA_PID \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..26f6a3e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +langchain +langchain-community +langchain-huggingface +langchain-postgres +langchain-chroma +psycopg2-binary +unstructured[pdf] +pdf2image +python-telegram-bot +streamlit +schedule +requests \ No newline at end of file diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..ba6dcf6 --- /dev/null +++ b/src/app.py @@ -0,0 +1,67 @@ +import streamlit as st +from langchain.chains import create_retrieval_chain +from langchain.chains.combine_documents import create_stuff_documents_chain +from langchain_core.prompts import ChatPromptTemplate +from src.rag_core import get_retriever, get_llm + +st.set_page_config(page_title="RAG Chat", layout="wide") +st.title("Paperless-NGX RAG Assistant") + +retriever, vectorstore, _ = get_retriever() +llm = get_llm() + +# Prompt Template +system_prompt = ( + "Du bist ein hilfreicher Assistent. Nutze den folgenden Kontext, um die Frage zu beantworten. " + "Wenn du die Antwort nicht weißt, sage einfach, dass du sie nicht weißt.\n\n" + "{context}" +) +prompt = ChatPromptTemplate.from_messages([ + ("system", system_prompt), + ("human", "{input}"), +]) + +question_answer_chain = create_stuff_documents_chain(llm, prompt) + +# Optionale Filter-UI +st.sidebar.header("Filter") +filter_id = st.sidebar.text_input("Nur in Document ID suchen (optional):") + +if "messages" not in st.session_state: + st.session_state.messages = [] + +for msg in st.session_state.messages: + with st.chat_message(msg["role"]): + st.markdown(msg["content"]) + +if prompt_input := st.chat_input("Stelle eine Frage zu deinen Dokumenten..."): + st.session_state.messages.append({"role": "user", "content": prompt_input}) + with st.chat_message("user"): + st.markdown(prompt_input) + + with st.chat_message("assistant"): + # Dynamischer Retriever mit Metadaten-Filter + search_kwargs = {"k": 3} + if filter_id: + search_kwargs["filter"] = {"paperless_id": int(filter_id)} + + # Override search_kwargs temporär + retriever.search_kwargs = search_kwargs + + rag_chain = create_retrieval_chain(retriever, question_answer_chain) + + with st.spinner("Denke nach..."): + response = rag_chain.invoke({"input": prompt_input}) + answer = response["answer"] + sources = response.get("context", []) + + st.markdown(answer) + + if sources: + st.write("---") + st.write("**Quellen:**") + unique_sources = {doc.metadata.get("source") for doc in sources if doc.metadata.get("source")} + for s in unique_sources: + st.write(f"- {s}") + + st.session_state.messages.append({"role": "assistant", "content": answer}) \ No newline at end of file diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..e354cb6 --- /dev/null +++ b/src/database.py @@ -0,0 +1,54 @@ +import os +import psycopg2 +from psycopg2.extras import RealDictCursor + +def get_db_connection(): + return psycopg2.connect( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=os.getenv("POSTGRES_PORT", "5432"), + database=os.getenv("POSTGRES_DB", "ragdb"), + user=os.getenv("POSTGRES_USER", "raguser"), + password=os.getenv("POSTGRES_PASSWORD", "ragpass") + ) + +def init_db(): + conn = get_db_connection() + cur = conn.cursor() + # Tabelle für Delta Sync + cur.execute(''' + CREATE TABLE IF NOT EXISTS sync_state ( + paperless_id INTEGER PRIMARY KEY, + modified_at TIMESTAMP, + checksum TEXT + ) + ''') + conn.commit() + cur.close() + conn.close() + +def get_sync_state(): + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT paperless_id, modified_at FROM sync_state") + rows = cur.fetchall() + conn.close() + return {row['paperless_id']: row['modified_at'] for row in rows} + +def update_sync_state(paperless_id, modified_at): + conn = get_db_connection() + cur = conn.cursor() + cur.execute(''' + INSERT INTO sync_state (paperless_id, modified_at) + VALUES (%s, %s) + ON CONFLICT (paperless_id) DO UPDATE + SET modified_at = EXCLUDED.modified_at + ''', (paperless_id, modified_at)) + conn.commit() + conn.close() + +def delete_sync_state(paperless_id): + conn = get_db_connection() + cur = conn.cursor() + cur.execute("DELETE FROM sync_state WHERE paperless_id = %s", (paperless_id,)) + conn.commit() + conn.close() \ No newline at end of file diff --git a/src/ingest_job.py b/src/ingest_job.py new file mode 100644 index 0000000..42b85f2 --- /dev/null +++ b/src/ingest_job.py @@ -0,0 +1,93 @@ +import os +import tempfile +import requests +from datetime import datetime +from langchain_community.document_loaders import UnstructuredPDFLoader +from src.database import init_db, get_sync_state, update_sync_state, delete_sync_state +from src.rag_core import get_retriever + +PAPERLESS_URL = os.getenv("PAPERLESS_URL") +PAPERLESS_TOKEN = os.getenv("PAPERLESS_TOKEN") +HEADERS = {"Authorization": f"Token {PAPERLESS_TOKEN}"} + +def fetch_paperless_documents(): + url = f"{PAPERLESS_URL}/api/documents/" + documents = [] + while url: + resp = requests.get(url, headers=HEADERS).json() + documents.extend(resp['results']) + url = resp.get('next') + return documents + +def download_pdf(doc_id): + url = f"{PAPERLESS_URL}/api/documents/{doc_id}/download/" + resp = requests.get(url, headers=HEADERS) + if resp.status_code == 200: + fd, path = tempfile.mkstemp(suffix=".pdf") + with os.fdopen(fd, 'wb') as f: + f.write(resp.content) + return path + return None + +def process_and_ingest(doc_id, pdf_path, metadata): + retriever, _, _ = get_retriever() + # Hi-Res mode um Tabellen korrekt zu extrahieren + loader = UnstructuredPDFLoader(pdf_path, strategy="hi_res") + docs = loader.load() + + for d in docs: + d.metadata.update(metadata) + + # ParentDocumentRetriever splittet automatisch in Parent/Child und speichert sie + retriever.add_documents(docs, ids=[str(doc_id)]) + +def remove_deleted_documents(deleted_ids): + retriever, vectorstore, store = get_retriever() + for doc_id in deleted_ids: + # Lösche aus Chroma (Child Chunks) - benötigt custom Logik via Metadata + # In Chroma löschen wir alles mit der doc_id in den Metadaten + vectorstore.delete(where={"paperless_id": doc_id}) + # Lösche aus DocStore + store.mdelete([str(doc_id)]) + # DB Sync löschen + delete_sync_state(doc_id) + print(f"Gelöscht: {doc_id}") + +def run_sync(): + print("Starte Paperless Delta-Sync...") + init_db() + current_state = get_sync_state() + paperless_docs = fetch_paperless_documents() + + active_paperless_ids = set() + + for p_doc in paperless_docs: + doc_id = p_doc['id'] + modified_at_str = p_doc['modified'] # ISO Format + active_paperless_ids.add(doc_id) + + # Delta Check + if doc_id not in current_state or str(current_state[doc_id]) != modified_at_str: + print(f"Verarbeite neues/geändertes Dokument: {doc_id}") + pdf_path = download_pdf(doc_id) + if pdf_path: + metadata = { + "paperless_id": doc_id, + "title": p_doc.get("title", ""), + "source": f"{PAPERLESS_URL}/documents/{doc_id}/details" + } + process_and_ingest(doc_id, pdf_path, metadata) + os.remove(pdf_path) + update_sync_state(doc_id, modified_at_str) + + # Löschlogik: Was in der DB ist, aber nicht mehr in Paperless + db_ids = set(current_state.keys()) + deleted_ids = db_ids - active_paperless_ids + if deleted_ids: + print(f"Entferne gelöschte Dokumente: {deleted_ids}") + remove_deleted_documents(deleted_ids) + + print("Sync abgeschlossen.") + +if __name__ == "__main__": + run_sync() \ No newline at end of file diff --git a/src/rag_core.py b/src/rag_core.py new file mode 100644 index 0000000..8b30e79 --- /dev/null +++ b/src/rag_core.py @@ -0,0 +1,45 @@ +import os +from langchain_community.llms import Ollama +from langchain_huggingface import HuggingFaceEmbeddings +from langchain_chroma import Chroma +from langchain.retrievers import ParentDocumentRetriever +from langchain.storage import create_kv_docstore +from langchain.storage._lc_store import create_kv_docstore +from langchain.storage import LocalFileStore +from langchain_text_splitters import RecursiveCharacterTextSplitter + +def get_llm(): + return Ollama( + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"), + model="granite4:tiny-h" + ) + +def get_embeddings(): + return HuggingFaceEmbeddings(model_name="intfloat/multilingual-e5-small") # Gut für Deutsch/Englisch + +def get_retriever(): + chroma_client = Chroma( + collection_name="rag_collection", + embedding_function=get_embeddings(), + persist_directory="./chroma_data" if not os.getenv("CHROMA_HOST") else None, + # Falls Chroma als Server läuft (wie im Docker Compose): + # chroma_server_host=os.getenv("CHROMA_HOST"), + # chroma_server_http_port=os.getenv("CHROMA_PORT") + ) + + # Store für die originalen (großen) Parent-Dokumente + # Für Skalierbarkeit im Docker nutzen wir hier einen simplen FileStore + # (oder man nutzt PostgresByteStore aus langchain-postgres) + fs = LocalFileStore("./docstore_data") + store = create_kv_docstore(fs) + + parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200) + child_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=50) + + retriever = ParentDocumentRetriever( + vectorstore=chroma_client, + docstore=store, + child_splitter=child_splitter, + parent_splitter=parent_splitter, + ) + return retriever, chroma_client, store \ No newline at end of file diff --git a/src/run_scheduler.py b/src/run_scheduler.py new file mode 100644 index 0000000..2c788d3 --- /dev/null +++ b/src/run_scheduler.py @@ -0,0 +1,20 @@ +import time +import schedule +from src.ingest_job import run_sync + +def job(): + try: + run_sync() + except Exception as e: + print(f"Fehler beim Sync: {e}") + +# Einmal nachts um 01:00 Uhr laufen lassen +schedule.every().day.at("01:00").do(job) + +if __name__ == "__main__": + print("Scheduler gestartet. Warte auf Ausführung...") + # Optional: Einmal beim Start ausführen + # job() + while True: + schedule.run_pending() + time.sleep(60) \ No newline at end of file diff --git a/src/telegram_bot.py b/src/telegram_bot.py new file mode 100644 index 0000000..b7aca33 --- /dev/null +++ b/src/telegram_bot.py @@ -0,0 +1,55 @@ +import os +from telegram import Update +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from langchain.chains import create_retrieval_chain +from langchain.chains.combine_documents import create_stuff_documents_chain +from langchain_core.prompts import ChatPromptTemplate +from src.rag_core import get_retriever, get_llm + +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +ALLOWED_USERS = [int(u) for u in os.getenv("ALLOWED_TELEGRAM_USERS", "").split(",") if u] + +retriever, _, _ = get_retriever() +llm = get_llm() +prompt = ChatPromptTemplate.from_messages([ + ("system", "Nutze folgenden Kontext für die Antwort:\n\n{context}"), + ("human", "{input}"), +]) +chain = create_retrieval_chain(retriever, create_stuff_documents_chain(llm, prompt)) + +def check_auth(update: Update) -> bool: + return update.effective_user.id in ALLOWED_USERS + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not check_auth(update): + await update.message.reply_text("Zugriff verweigert.") + return + await update.message.reply_text("Hallo! Ich bin dein Paperless RAG-Bot. Frag mich etwas.") + +async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not check_auth(update): + return + + question = update.message.text + # "Denke nach" Indikator + await context.bot.send_chat_action(chat_id=update.effective_chat.id, action='typing') + + try: + response = chain.invoke({"input": question}) + answer = response["answer"] + sources = list({doc.metadata.get("source", "Unbekannt") for doc in response.get("context", [])}) + + reply = f"{answer}\n\n**Quellen:**\n" + "\n".join(f"- {s}" for s in sources) + await update.message.reply_text(reply, parse_mode='Markdown') + except Exception as e: + await update.message.reply_text(f"Es gab einen Fehler: {str(e)}") + +def main(): + app = Application.builder().token(TOKEN).build() + app.add_handler(CommandHandler("start", start)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) + print("Telegram Bot gestartet...") + app.run_polling() + +if __name__ == "__main__": + main() \ No newline at end of file