Add sync_missing_docs.py: Dokumentenabgleich job_matching -> apply4jobs.de

- Vergleicht profile_documents (NAS) gegen anythingllm_vectors (apply4jobs.de) per filename/title
- Embeddings werden für die Zielseite neu berechnet (multilingual-e5-small, 384 Dim),
  da OpenAI-1536-Dim-Vektoren aus job_matching inkompatibel sind
- diff/sync Subcommands, --dry-run, --workspace-id, --force (Update/Re-Sync bestehender Titel)
- JM_DB_HOST auf Tailscale-IP umgestellt (100.81.64.115) für Erreichbarkeit außerhalb des Heimnetzes
- README um Tool 4 ergänzt und Hetzner->DO-Umzug dokumentiert
This commit is contained in:
2026-07-28 17:25:40 +02:00
parent 2a45580f97
commit 4bf034b2a5
2 changed files with 398 additions and 11 deletions
+71 -11
View File
@@ -1,7 +1,7 @@
# RAG Ingestion Tools
Lokale Python-Tools zum Importieren von Dokumenten in pgvector-Vektordatenbanken.
Drei eigenständige Scripts für unterschiedliche Zielplattformen und Anwendungsfälle,
Vier eigenständige Scripts für unterschiedliche Zielplattformen und Anwendungsfälle,
plus zwei gemeinsame Module für Extraktion und KI-Normalisierung.
---
@@ -10,15 +10,17 @@ plus zwei gemeinsame Module für Extraktion und KI-Normalisierung.
| Tool | Wofür? | Zielplattform | Verbindung | .env-Datei |
|---|---|---|---|---|
| `ingest_V2.py` | Fremde Dokumente, Scans, Bilder — mit automatischer Strukturierung | Hetzner · anythingllm DB | SSH-Tunnel | `.env.ingest` |
| `ingest_anythingllm.py` | Eigene, bereits strukturierte Dokumente (Templates) | Hetzner · anythingllm DB | SSH-Tunnel | `.env.ingest` |
| `ingest_V2.py` | Fremde Dokumente, Scans, Bilder — mit automatischer Strukturierung | apply4jobs.de · anythingllm DB | SSH-Tunnel | `.env.ingest` |
| `ingest_anythingllm.py` | Eigene, bereits strukturierte Dokumente (Templates) | apply4jobs.de · anythingllm DB | SSH-Tunnel | `.env.ingest` |
| `ingest_job_matching.py` | Stellenanzeigen & Profil-Dokumente für Job-Matching | NAS · job_matching DB | Direkt (Port 5433) | `.env.job_matching` |
| `sync_missing_docs.py` | Dokumentenabgleich: fehlende/aktualisierte Profil-Dokumente von job_matching nach apply4jobs.de spiegeln | NAS → apply4jobs.de | Direkt + SSH-Tunnel | `.env.ingest` + `.env.job_matching` |
**Wann welches Tool?**
- Du hast ein **fremdes CV, einen Scan oder ein Bild**`ingest_V2.py` (OCR + KI-Normalisierung)
- Du hast ein **eigenes, fertiges Markdown-Dokument** (z.B. ausgefülltes Template) → `ingest_anythingllm.py`
- Du willst **Stellenanzeigen oder Profil-Dokumente** für Job-Matching einpflegen → `ingest_job_matching.py`
- Du willst, dass Dokumente aus der **Job-Matching-Plattform auch in der apply4jobs.de-RAG-DB** verfügbar sind (Profil-Kern, Skill-Matrix, Projektbeschreibungen etc.) → `sync_missing_docs.py`
---
@@ -26,9 +28,10 @@ plus zwei gemeinsame Module für Extraktion und KI-Normalisierung.
```
rag-ingestion/
├── ingest_V2.py Vollpipeline v2: OCR + KI-Normalisierung (→ Hetzner)
├── ingest_anythingllm.py Einfaches Script v1: nur MD/TXT/PDF (→ Hetzner)
├── ingest_V2.py Vollpipeline v2: OCR + KI-Normalisierung (→ apply4jobs.de)
├── ingest_anythingllm.py Einfaches Script v1: nur MD/TXT/PDF (→ apply4jobs.de)
├── ingest_job_matching.py Job-Matching Script: direkt auf NAS (→ NAS Port 5433)
├── sync_missing_docs.py Abgleich job_matching → apply4jobs.de (fehlende/geänderte Dokumente spiegeln)
├── extractor.py Modul: Text-Extraktion + OCR (wird von ingest_V2 genutzt)
├── normalizer.py Modul: KI-Normalisierung via Claude (wird von ingest_V2 genutzt)
@@ -53,7 +56,11 @@ rag-ingestion/
DOCX-Dateien, fremde CVs — die zunächst per OCR extrahiert und dann via Claude KI
automatisch in die einheitliche Template-Struktur transformiert werden.
**Zielplattform:** pgvector-Datenbank (`anythingllm`) auf Hetzner-Server via SSH-Tunnel.
**Zielplattform:** pgvector-Datenbank (`anythingllm`) für `ai.apply4jobs.de` via SSH-Tunnel.
> **Hinweis (Stand 2026-07):** Server + Domain wurden von Hetzner auf einen Digital-Ocean-
> Droplet umgezogen. `SSH_HOST` in `.env.ingest` muss auf die aktuelle DO-IP zeigen —
> die alte Hetzner-IP (`46.225.225.226`) ist nicht mehr gültig.
**Benötigt:** `extractor.py` und `normalizer.py` im gleichen Verzeichnis.
@@ -156,7 +163,7 @@ Erkannte Typen: `arbeitszeugnis`, `cv`, `lebenslauf`, `anschreiben`,
Dokumenten — z.B. ausgefüllte Templates, eigene Markdown-Dateien oder einfache PDFs
die keiner OCR oder KI-Normalisierung bedürfen.
**Zielplattform:** pgvector-Datenbank (`anythingllm`) auf Hetzner-Server via SSH-Tunnel.
**Zielplattform:** pgvector-Datenbank (`anythingllm`) für `ai.apply4jobs.de` via SSH-Tunnel.
Gleiche Zieldatenbank wie `ingest_V2.py` — unterschiedlicher Verarbeitungsweg.
### Unterstützte Formate
@@ -180,8 +187,12 @@ python ingest_anythingllm.py list
dedizierte Job-Matching-Datenbank. Diese Datenbank wird von einem separaten
Job-Matching-Workflow genutzt um Stellenanzeigen mit dem eigenen Profil abzugleichen.
**Zielplattform:** pgvector-Datenbank (`job_matching`) direkt auf dem lokalen NAS
(192.168.178.128, Port 5433) — kein SSH-Tunnel erforderlich, nur im lokalen Netzwerk erreichbar.
**Zielplattform:** pgvector-Datenbank (`job_matching`) direkt auf dem NAS
(Port 5433) — kein SSH-Tunnel erforderlich.
> `JM_DB_HOST` steht auf die Tailscale-IP `100.81.64.115`, damit die Verbindung auch
> außerhalb des Heimnetzes funktioniert. Im lokalen WLAN geht alternativ auch die
> LAN-IP `192.168.178.128` (kann sich per DHCP ändern).
Embeddings via OpenAI `text-embedding-3-small` (1536 Dimensionen, kostenpflichtig).
@@ -196,6 +207,54 @@ python ingest_job_matching.py clear "stellenanzeige.pdf"
---
## Tool 4: `sync_missing_docs.py` — Dokumentenabgleich job_matching → apply4jobs.de
**Verwendungszweck:** Job-Matching-Plattform und `ai.apply4jobs.de`/`apply4jobs.de` nutzen
zwei getrennte pgvector-Datenbanken (NAS vs. DO-Droplet). Dieses Script gleicht sie ab, damit
Profil-Dokumente, die in der Job-Matching-Plattform erstellt/hochgeladen wurden (Skill-Matrix,
Rahmenbedingungen, Zielstellen-Profil, Muster-Anschreiben, Elevator-Pitch, Projektbeschreibungen,
CV, Arbeitszeugnisse etc.), auch für die Webseiten-RAG verfügbar sind.
**Matching-Kriterium:** `filename` (job_matching.profile_documents) ↔ `metadata->>'title'`
(anythingllm_vectors).
**Embeddings:** werden für die Zielseite immer **neu berechnet** (lokal, `multilingual-e5-small`,
384 Dim) — die OpenAI-1536-Dim-Vektoren aus job_matching sind mit der 384-Dim-Spalte in
`anythingllm_vectors` nicht kompatibel. Nur der Text wird übernommen.
**Voraussetzung:** benötigt beide `.env`-Dateien (`.env.ingest` für die SSH-Tunnel-Verbindung
zum Ziel, `.env.job_matching` für die Quelle) sowie `sentence-transformers` (aus `requirements.txt`).
### Verwendung
```bash
# Nur anzeigen, welche Dokumente in apply4jobs.de fehlen (kein Schreibzugriff)
python sync_missing_docs.py diff
# Optional auf einen Workspace eingrenzen
python sync_missing_docs.py diff --workspace-id 1
# Fehlende Dokumente übertragen
python sync_missing_docs.py sync --dry-run # Simulation, nichts wird geschrieben
python sync_missing_docs.py sync # tatsächlich schreiben
# Auch bereits vorhandene Titel neu einlesen (Update/Re-Sync):
# löscht die alten Chunks in anythingllm_vectors und ersetzt sie mit dem
# aktuellen Stand aus job_matching
python sync_missing_docs.py sync --force
python sync_missing_docs.py sync --force --dry-run
```
### Flags
| Flag | Beschreibung |
|---|---|
| `--workspace-id <id>` | Nur Dokumente eines bestimmten Workspace aus job_matching berücksichtigen |
| `--dry-run` | Nur anzeigen, nichts in die Zieldatenbank schreiben |
| `--force` | Auch bereits vorhandene Titel neu einlesen (löscht + ersetzt statt nur zu ergänzen) |
---
## Gemeinsame Infrastruktur
### Embedding-Modelle
@@ -205,13 +264,14 @@ python ingest_job_matching.py clear "stellenanzeige.pdf"
| `ingest_V2.py` | multilingual-e5-small (lokal) | 384 | kostenlos |
| `ingest_anythingllm.py` | multilingual-e5-small (lokal) | 384 | kostenlos |
| `ingest_job_matching.py` | OpenAI text-embedding-3-small | 1536 | kostenpflichtig |
| `sync_missing_docs.py` | multilingual-e5-small (lokal, für Ziel-DB neu berechnet) | 384 | kostenlos |
### Umgebungsvariablen
| Datei | Verwendet von | Inhalt |
|---|---|---|
| `.env.ingest` | `ingest_V2.py`, `ingest_anythingllm.py` | ANTHROPIC_API_KEY, SSH, DB |
| `.env.job_matching` | `ingest_job_matching.py` | OPENAI_API_KEY, NAS-DB |
| `.env.ingest` | `ingest_V2.py`, `ingest_anythingllm.py`, `sync_missing_docs.py` | ANTHROPIC_API_KEY, SSH, DB |
| `.env.job_matching` | `ingest_job_matching.py`, `sync_missing_docs.py` | OPENAI_API_KEY, NAS-DB |
---
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
sync_missing_docs.py
Gleicht Dokumente zwischen der Job-Matching-DB (NAS, job_matching.profile_documents)
und der apply4jobs.de-RAG-DB (DO-Droplet, anythingllm_vectors) ab.
Matching-Kriterium: filename (job_matching) <-> metadata->>'title' (anythingllm_vectors)
Fehlende Dokumente werden:
1. aus profile_documents vollständig zusammengesetzt (Chunks in chunk_index-Reihenfolge)
2. neu gechunkt (gleiche Logik wie ingest.py: token-basiert, CHUNK_SIZE/CHUNK_OVERLAP)
3. mit dem LOKALEN Modell multilingual-e5-small neu embedded
(die OpenAI-1536-Dim-Embeddings aus job_matching sind NICHT kompatibel mit der
384-Dim anythingllm_vectors-Spalte -> Text wird übernommen, Vektor neu berechnet)
4. via SSH-Tunnel in anythingllm_vectors eingefügt (gleiches Format wie ingest.py)
Nutzung:
python sync_missing_docs.py diff # nur anzeigen, was fehlt
python sync_missing_docs.py sync # fehlende Dokumente übertragen
python sync_missing_docs.py sync --dry-run # simulieren, nichts schreiben
python sync_missing_docs.py sync --workspace-id 1 # nur bestimmten Workspace abgleichen
python sync_missing_docs.py sync --force # ALLE Dokumente neu übertragen
# (bestehende Titel werden vorher
# in anythingllm_vectors gelöscht
# und mit dem aktuellen Stand aus
# job_matching neu eingefügt)
Voraussetzung: SSH_HOST in .env.ingest muss auf den aktuellen DO-Droplet zeigen
(aktuell noch die alte Hetzner-IP -> bitte vor dem Lauf aktualisieren).
"""
import os
import uuid
import json
import argparse
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
import psycopg2
from psycopg2.extras import execute_values, RealDictCursor
from sshtunnel import SSHTunnelForwarder
import tiktoken
# --- .env laden ---
load_dotenv(".env.ingest") # Ziel-DB (anythingllm_vectors) via SSH-Tunnel
load_dotenv(".env.job_matching") # Quelle (job_matching, NAS, direkt)
# Ziel (apply4jobs.de RAG-DB)
SSH_HOST = os.getenv("SSH_HOST")
SSH_USER = os.getenv("SSH_USER", "root")
SSH_KEY_PATH = os.path.expanduser(os.getenv("SSH_KEY_PATH", "~/.ssh/id_ed25519"))
SSH_KEY_PASSPHRASE = os.getenv("SSH_KEY_PASSPHRASE", None)
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT = int(os.getenv("DB_PORT", 5432))
DB_NAME = os.getenv("DB_NAME", "anythingllm")
DB_USER = os.getenv("DB_USER", "anythingllm")
DB_PASSWORD = os.getenv("DB_PASSWORD")
NAMESPACE = os.getenv("NAMESPACE", "mein-workspace")
CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", 500))
CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", 50))
EMBED_MODEL = "intfloat/multilingual-e5-small"
# Quelle (job_matching, NAS)
JM_DB_HOST = os.getenv("JM_DB_HOST", "192.168.178.128")
JM_DB_PORT = int(os.getenv("JM_DB_PORT", 5433))
JM_DB_NAME = os.getenv("JM_DB_NAME", "job_matching")
JM_DB_USER = os.getenv("JM_DB_USER")
JM_DB_PASSWORD = os.getenv("JM_DB_PASSWORD")
embed_model = None # lazy load, wie in ingest.py
# --- Quelle: job_matching (NAS, direkt) ---
def get_jm_connection():
return psycopg2.connect(
host=JM_DB_HOST, port=JM_DB_PORT,
dbname=JM_DB_NAME, user=JM_DB_USER, password=JM_DB_PASSWORD
)
def list_jm_filenames(workspace_id: int | None) -> set[str]:
"""Alle distinct filenames aus profile_documents, optional nach workspace_id gefiltert."""
conn = get_jm_connection()
cur = conn.cursor()
if workspace_id is not None:
cur.execute(
"SELECT DISTINCT filename FROM profile_documents WHERE workspace_id = %s",
(workspace_id,)
)
else:
cur.execute("SELECT DISTINCT filename FROM profile_documents")
rows = {r[0] for r in cur.fetchall()}
cur.close()
conn.close()
return rows
def fetch_jm_document_text(filename: str, workspace_id: int | None) -> str:
"""Setzt ein Dokument aus seinen Chunks (chunk_index-Reihenfolge) wieder zusammen."""
conn = get_jm_connection()
cur = conn.cursor()
if workspace_id is not None:
cur.execute(
"""SELECT content FROM profile_documents
WHERE filename = %s AND workspace_id = %s
ORDER BY chunk_index ASC""",
(filename, workspace_id)
)
else:
cur.execute(
"""SELECT content FROM profile_documents
WHERE filename = %s
ORDER BY chunk_index ASC""",
(filename,)
)
rows = cur.fetchall()
cur.close()
conn.close()
return "\n\n".join(r[0] for r in rows)
# --- Ziel: anythingllm_vectors (DO-Droplet, via SSH-Tunnel) ---
def get_target_connection(tunnel_port: int):
return psycopg2.connect(
host="127.0.0.1", port=tunnel_port,
dbname=DB_NAME, user=DB_USER, password=DB_PASSWORD
)
def list_target_titles(cur) -> set[str]:
cur.execute(
"SELECT DISTINCT metadata->>'title' FROM anythingllm_vectors WHERE namespace = %s",
(NAMESPACE,)
)
return {r[0] for r in cur.fetchall()}
def delete_target_title(cur, title: str):
"""Löscht alle Chunks eines Titels im Ziel-Namespace (für --force Re-Sync)."""
cur.execute(
"DELETE FROM anythingllm_vectors WHERE metadata->>'title' = %s AND namespace = %s",
(title, NAMESPACE)
)
# --- Chunking (identisch zu ingest.py) ---
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = min(start + chunk_size, len(tokens))
chunks.append(enc.decode(tokens[start:end]))
start += chunk_size - overlap
return chunks
# --- Embeddings (lokal, wie ingest.py) ---
def get_embeddings(texts: list[str]) -> list[list[float]]:
global embed_model
if embed_model is None:
from sentence_transformers import SentenceTransformer
print(" ⏳ Lade Embedding-Modell (einmalig)...")
embed_model = SentenceTransformer(EMBED_MODEL)
prefixed = [f"passage: {t}" for t in texts]
return embed_model.encode(prefixed).tolist()
def insert_chunks(cur, chunks: list[str], embeddings: list[list[float]], filename: str, source_note: str):
now = datetime.now().isoformat()
records = []
for chunk, embedding in zip(chunks, embeddings):
metadata = {
"id": str(uuid.uuid4()),
"url": f"jobmatch-sync://{filename}",
"text": chunk,
"title": filename,
"docSource": source_note,
"published": now,
"wordCount": len(chunk.split()),
"chunkSource": f"jobmatch-sync://{filename}",
}
records.append((str(uuid.uuid4()), json.dumps(metadata), NAMESPACE, embedding))
execute_values(
cur,
"INSERT INTO anythingllm_vectors (id, metadata, namespace, embedding) VALUES %s",
records,
template="(%s::uuid, %s::jsonb, %s, %s::vector)"
)
# --- Diff ---
def compute_missing(workspace_id: int | None) -> list[str]:
jm_files, _target_titles, missing = _compute_state(workspace_id)
return missing
def _compute_state(workspace_id: int | None) -> tuple[set[str], set[str], list[str]]:
"""Liefert (jm_files, target_titles, missing) in einem Rutsch, damit sync/--force
nicht doppelt gegen beide DBs fragen muss."""
print("⏳ Lade Dateinamen aus job_matching (NAS)...")
jm_files = list_jm_filenames(workspace_id)
print(f"{len(jm_files)} Dokumente in job_matching")
print("⏳ Verbinde mit apply4jobs.de-DB via SSH-Tunnel...")
with SSHTunnelForwarder(
(SSH_HOST, 22), ssh_username=SSH_USER, ssh_pkey=SSH_KEY_PATH,
ssh_private_key_password=SSH_KEY_PASSPHRASE,
remote_bind_address=("127.0.0.1", DB_PORT)
) as tunnel:
conn = get_target_connection(tunnel.local_bind_port)
cur = conn.cursor()
target_titles = list_target_titles(cur)
cur.close()
conn.close()
print(f"{len(target_titles)} Dokumente in anythingllm_vectors (Namespace '{NAMESPACE}')")
missing = sorted(jm_files - target_titles)
return jm_files, target_titles, missing
# --- Sync ---
def sync_missing(workspace_id, dry_run, force=False):
jm_files, target_titles, missing = _compute_state(workspace_id)
if force:
to_process = sorted(jm_files)
updates = sorted(jm_files & target_titles)
new_docs = sorted(jm_files - target_titles)
else:
to_process = missing
updates = []
new_docs = missing
if not to_process:
print("\n✅ Keine fehlenden Dokumente. Beide DBs sind synchron.")
return
if force:
print(f"\n📋 {len(to_process)} Dokumente werden verarbeitet ({len(new_docs)} neu, {len(updates)} Update/Re-Sync):")
else:
print(f"\n📋 {len(to_process)} fehlende Dokumente:")
for f in to_process:
tag = " (Update)" if force and f in updates else ""
print(f" - {f}{tag}")
if dry_run:
print("\n🔎 Dry-Run: nichts geschrieben.")
return
print(f"\n⏳ Verbinde mit apply4jobs.de-DB via SSH-Tunnel...")
with SSHTunnelForwarder(
(SSH_HOST, 22), ssh_username=SSH_USER, ssh_pkey=SSH_KEY_PATH,
ssh_private_key_password=SSH_KEY_PASSPHRASE,
remote_bind_address=("127.0.0.1", DB_PORT)
) as tunnel:
conn = get_target_connection(tunnel.local_bind_port)
cur = conn.cursor()
success = 0
for filename in to_process:
print(f"\n📄 {filename}")
text = fetch_jm_document_text(filename, workspace_id)
if not text.strip():
print(" ⚠️ Leerer Inhalt, übersprungen.")
continue
chunks = chunk_text(text)
print(f"{len(chunks)} Chunks")
embeddings = get_embeddings(chunks)
print(f"{len(embeddings)} Embeddings (lokal, multilingual-e5-small)")
if force and filename in target_titles:
delete_target_title(cur, filename)
print(" 🗑️ Alte Chunks gelöscht (Re-Sync)")
insert_chunks(cur, chunks, embeddings, filename, source_note="jobmatch-sync-script")
conn.commit()
print(f" ✅ in anythingllm_vectors gespeichert (Namespace '{NAMESPACE}')")
success += 1
cur.close()
conn.close()
print(f"\n✅ Fertig: {success}/{len(to_process)} Dokumente übertragen.")
# --- CLI ---
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Dokumentenabgleich job_matching <-> apply4jobs.de")
sub = parser.add_subparsers(dest="command")
p_diff = sub.add_parser("diff", help="Nur anzeigen, welche Dokumente fehlen")
p_diff.add_argument("--workspace-id", type=int, default=None)
p_sync = sub.add_parser("sync", help="Fehlende Dokumente übertragen")
p_sync.add_argument("--workspace-id", type=int, default=None)
p_sync.add_argument("--dry-run", action="store_true")
p_sync.add_argument("--force", action="store_true",
help="Auch bereits vorhandene Titel neu einlesen (löscht alte Chunks und ersetzt sie)")
args = parser.parse_args()
if args.command == "diff":
missing = compute_missing(args.workspace_id)
if missing:
print(f"\n📋 {len(missing)} fehlende Dokumente:")
for f in missing:
print(f" - {f}")
else:
print("\n✅ Keine fehlenden Dokumente.")
elif args.command == "sync":
sync_missing(args.workspace_id, args.dry_run, args.force)
else:
parser.print_help()