From 4fea148d619cfe4010cde0ccbae3c8152efd8f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fr=C3=B6hlich?= Date: Tue, 28 Apr 2026 11:21:53 +0200 Subject: [PATCH] feat: v2 pipeline mit OCR + KI-Normalisierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractor.py: Text-Extraktion für MD/TXT/PDF/DOCX/Bilder inkl. OCR (Tesseract) - normalizer.py: KI-Normalisierung via Claude Haiku (Typ-Erkennung) + Sonnet (Transformation) - ingest.py: Vollpipeline v2 mit watch, delete, dry-run, quality-min Flags - ingest_anythingllm.py: Einfaches Script v1 (nur MD/TXT/PDF, kein OCR) - README.md: Alle drei Tools dokumentiert --- .gitignore | 11 + README.md | 225 +++++++++--- ingest_anythingllm.py | 323 +++++++++++++++++ ingest_job_matching.py | 341 ++++++++++++++++++ templates/Karriereziele_TEMPLATE.md | 29 ++ templates/Muster-Anschreiben_TEMPLATE.md | 30 ++ templates/Projektbeschreibung_TEMPLATE.md | 31 ++ templates/Rahmenbedingungen_TEMPLATE.md | 26 ++ templates/Referenzen_TEMPLATE.md | 25 ++ templates/Technologie-Bewertung_TEMPLATE.md | 64 ++++ .../Ueber-mich_Elevator-Pitch_TEMPLATE.md | 21 ++ .../Zertifikate_Weiterbildungen_TEMPLATE.md | 25 ++ templates/Zielstellen-Profil_TEMPLATE.md | 35 ++ 13 files changed, 1134 insertions(+), 52 deletions(-) create mode 100644 ingest_anythingllm.py create mode 100644 ingest_job_matching.py create mode 100644 templates/Karriereziele_TEMPLATE.md create mode 100644 templates/Muster-Anschreiben_TEMPLATE.md create mode 100644 templates/Projektbeschreibung_TEMPLATE.md create mode 100644 templates/Rahmenbedingungen_TEMPLATE.md create mode 100644 templates/Referenzen_TEMPLATE.md create mode 100644 templates/Technologie-Bewertung_TEMPLATE.md create mode 100644 templates/Ueber-mich_Elevator-Pitch_TEMPLATE.md create mode 100644 templates/Zertifikate_Weiterbildungen_TEMPLATE.md create mode 100644 templates/Zielstellen-Profil_TEMPLATE.md diff --git a/.gitignore b/.gitignore index ad53b70..6e98104 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,16 @@ +# Umgebung .env +.env.job_matching venv/ __pycache__/ *.pyc +*.pyo + +# macOS .DS_Store + +# Embedding-Modell Cache (wird automatisch heruntergeladen) +.cache/ + +# Ausgefüllte persönliche Dokumente nicht ins Repo +templates/ausgefuellt/ diff --git a/README.md b/README.md index 1373cbc..2a9b67d 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,183 @@ -# RAG Ingestion Script +# RAG Ingestion Tools -Lokales Python-Script um Dokumente (MD, TXT, PDF) in die pgvector-Datenbank auf dem Hetzner-Server zu importieren. +Lokale Python-Tools zum Importieren von Dokumenten in pgvector-Datenbanken. +Drei eigenständige Tools für unterschiedliche Zieldatenbanken und Anwendungsfälle. -## Setup +--- + +## Übersicht + +| Tool | Zieldatenbank | Verbindung | Besonderheit | +|---|---|---|---| +| `ingest.py` | Hetzner (anythingllm) | SSH-Tunnel | OCR + KI-Normalisierung | +| `ingest_anythingllm.py` | Hetzner (anythingllm) | SSH-Tunnel | Einfach, nur MD/TXT/PDF | +| `ingest_job_matching.py` | NAS 192.168.178.128 | Direkt (Port 5433) | Job-Matching-Datenbank | + +--- + +## Tool 1: `ingest.py` — Vollpipeline mit OCR + KI-Normalisierung + +Das Haupt-Tool der neuen Generation. Verarbeitet beliebige Dokumente (inkl. Scans +und Bilder) und normalisiert sie via Claude KI in die einheitliche Template-Struktur. + +**Abhängigkeiten:** `extractor.py`, `normalizer.py` (müssen im gleichen Verzeichnis liegen) + +### Unterstützte Formate + +`.md` `.txt` `.pdf` `.docx` `.png` `.jpg` `.jpeg` `.tif` `.tiff` `.bmp` `.webp` + +### Voraussetzungen + +```bash +# Tesseract (OCR, einmalig) +brew install tesseract tesseract-lang + +# Python-Pakete +pip install -r requirements.txt + +# .env konfigurieren +cp .env.example .env +# ANTHROPIC_API_KEY eintragen +``` + +### Verwendung + +```bash +# Einzelne Datei (mit KI-Normalisierung) +python ingest.py file ~/Downloads/fremdes_cv.pdf + +# Erst testen ohne DB-Speicherung +python ingest.py file ~/Desktop/scan.png --dry-run + +# Ohne KI-Normalisierung (z.B. eigene fertige Templates) +python ingest.py file ~/templates/ausgefuellt/mein_cv.md --no-normalize + +# Ganzes Verzeichnis +python ingest.py dir ~/Dokumente/bewerbungsunterlagen/ --force + +# Ordner live beobachten (neue Dateien automatisch verarbeiten) +python ingest.py watch ~/Desktop/scan-eingang/ + +# DB-Inhalt anzeigen +python ingest.py list + +# Dokument löschen +python ingest.py delete "fremdes_cv.pdf" +``` + +### Flags + +| Flag | Beschreibung | +|---|---| +| `--force` | Bestehende Chunks überschreiben | +| `--no-normalize` | KI-Normalisierung überspringen | +| `--dry-run` | Nur extrahieren + normalisieren, nicht speichern | +| `--quality-min 0.5` | Mindest-Qualitäts-Score (0.0–1.0) | + +### KI-Normalisierung + +Erkennt den Dokumenttyp automatisch und transformiert den Inhalt in die passende +Template-Struktur aus `templates/`. Nutzt die ausgefüllten Beispiele aus +`templates/ausgefuellt/` als Few-Shot-Referenz. + +- Typ-Erkennung: Claude Haiku (günstig + schnell) +- Transformation: Claude Sonnet +- Fehlende Felder werden mit `[FEHLT]` markiert, nicht halluziniert + +--- + +## Tool 2: `ingest_anythingllm.py` — Einfaches Ingestion-Script + +Schlankes Tool ohne OCR und ohne KI-Normalisierung. Direkte Übernahme von +MD/TXT/PDF-Dateien in die anythingllm-Datenbank auf dem Hetzner-Server. + +Geeignet für: eigene, bereits fertig strukturierte Dokumente. + +### Unterstützte Formate + +`.md` `.txt` `.pdf` + +### Voraussetzungen + +```bash +pip install openai psycopg2-binary pgvector sshtunnel python-dotenv pypdf tiktoken sentence-transformers +cp .env.example .env +# OPENAI_API_KEY eintragen (wird nur für Client-Init benötigt, Embeddings laufen lokal) +``` + +### Verwendung + +```bash +# Einzelne Datei +python ingest_anythingllm.py file ~/Dokumente/Lebenslauf.md + +# Mit --force (bestehende Chunks überschreiben) +python ingest_anythingllm.py file ~/Dokumente/Lebenslauf.md --force + +# Ganzes Verzeichnis +python ingest_anythingllm.py dir ~/Dokumente/bewerbung/ + +# DB-Inhalt anzeigen +python ingest_anythingllm.py list +``` + +--- + +## Tool 3: `ingest_job_matching.py` — Job-Matching-Datenbank (NAS) + +Importiert Dokumente direkt in die `job_matching`-Datenbank auf dem lokalen NAS +(192.168.178.128, Port 5433). Kein SSH-Tunnel erforderlich. + +### Voraussetzungen + +```bash +pip install -r requirements.txt +cp .env.job_matching .env.job_matching.local +# Werte in .env.job_matching.local anpassen +``` + +### Verwendung + +```bash +python ingest_job_matching.py file ~/Dokumente/stellenanzeige.pdf +python ingest_job_matching.py dir ~/Dokumente/stellen/ +python ingest_job_matching.py list +``` + +--- + +## Gemeinsame Infrastruktur + +### Embedding-Modell + +Alle Tools verwenden `intfloat/multilingual-e5-small` (384 Dimensionen) lokal. +Wird beim ersten Aufruf automatisch heruntergeladen (~120 MB). + +### Templates + +``` +templates/ +├── *_TEMPLATE.md Ziel-Strukturvorlagen (leer) +└── ausgefuellt/ Ausgefüllte Referenzbeispiele (für KI-Few-Shot) +``` + +### Umgebungsvariablen + +| Datei | Verwendet von | +|---|---| +| `.env` | `ingest.py`, `ingest_anythingllm.py` | +| `.env.job_matching` | `ingest_job_matching.py` | + +--- + +## Setup (komplett, einmalig) ```bash cd ~/Projekte/rag-ingestion python3 -m venv venv source venv/bin/activate pip install -r requirements.txt -``` - -Umgebungsvariablen konfigurieren: - -```bash +brew install tesseract tesseract-lang # nur für ingest.py (OCR) cp .env.example .env -nano .env +nano .env # API-Keys + DB-Zugangsdaten eintragen ``` - -## Verwendung - -### Einzelne Datei importieren - -```bash -python ingest.py file ~/Dokumente/Lebenslauf.md -``` - -### Mit --force (bestehende Chunks überschreiben) - -```bash -python ingest.py file ~/Dokumente/Lebenslauf.md --force -``` - -### Ganzes Verzeichnis importieren - -```bash -python ingest.py dir ~/Dokumente/bewerbung/ -``` - -### Alle Dokumente in der DB anzeigen - -```bash -python ingest.py list -``` - -## Unterstützte Formate - -- `.md` Markdown -- `.txt` Plaintext -- `.pdf` PDF (via pypdf) - -## Embedding-Modell - -Verwendet `intfloat/multilingual-e5-small` (384 Dimensionen) – identisch mit der RAG-API auf dem Server. Das Modell wird beim ersten Aufruf automatisch heruntergeladen (~120MB). - -## Geplante Erweiterungen - -- [ ] Tkinter UI für komfortableres Einpflegen -- [ ] Drag & Drop von Dateien -- [ ] Fortschrittsanzeige -- [ ] Dokument-Verwaltung (löschen, aktualisieren) diff --git a/ingest_anythingllm.py b/ingest_anythingllm.py new file mode 100644 index 0000000..d0ed1b8 --- /dev/null +++ b/ingest_anythingllm.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +RAG Ingestion Script für apply4jobs.de +Liest Dokumente (MD, TXT, PDF), chunked sie und speichert Embeddings in pgvector. + +Späterer Ausbau: Tkinter UI +""" + +import os +import uuid +import json +from datetime import datetime +from pathlib import Path +from dotenv import load_dotenv +from openai import OpenAI +import psycopg2 +from psycopg2.extras import execute_values +from sshtunnel import SSHTunnelForwarder +import tiktoken + +# PDF Support (optional) +try: + from pypdf import PdfReader + PDF_SUPPORT = True +except ImportError: + PDF_SUPPORT = False + +load_dotenv() + +# --- Konfiguration --- +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +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" + +client = OpenAI(api_key=OPENAI_API_KEY) +embed_model = None # lazy load + + +# --- Text-Extraktion --- + +def read_file(path: Path) -> str: + """Liest MD, TXT oder PDF und gibt den Text zurück.""" + suffix = path.suffix.lower() + if suffix in (".md", ".txt"): + return path.read_text(encoding="utf-8") + elif suffix == ".pdf": + if not PDF_SUPPORT: + raise ImportError("pypdf nicht installiert: pip install pypdf") + reader = PdfReader(str(path)) + return "\n\n".join(page.extract_text() or "" for page in reader.pages) + else: + raise ValueError(f"Nicht unterstütztes Dateiformat: {suffix}") + + +# --- Chunking --- + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]: + """Teilt Text in überlappende Chunks auf (token-basiert).""" + enc = tiktoken.get_encoding("cl100k_base") + tokens = enc.encode(text) + chunks = [] + start = 0 + while start < len(tokens): + end = min(start + chunk_size, len(tokens)) + chunk_tokens = tokens[start:end] + chunks.append(enc.decode(chunk_tokens)) + start += chunk_size - overlap + return chunks + + +# --- Embeddings --- + +def get_embeddings(texts: list[str]) -> list[list[float]]: + """Erstellt Embeddings via multilingual-e5-small (lokal).""" + 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() + + +# --- Datenbank --- + +def get_db_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 check_existing(cur, source_title: str) -> int: + """Prüft ob Dokument bereits in der DB vorhanden ist.""" + cur.execute( + "SELECT COUNT(*) FROM anythingllm_vectors WHERE metadata->>'title' = %s AND namespace = %s", + (source_title, NAMESPACE) + ) + return cur.fetchone()[0] + + +def delete_existing(cur, source_title: str): + """Löscht alle Chunks eines Dokuments.""" + cur.execute( + "DELETE FROM anythingllm_vectors WHERE metadata->>'title' = %s AND namespace = %s", + (source_title, NAMESPACE) + ) + print(f" 🗑️ Bestehende Chunks für '{source_title}' gelöscht.") + + +def insert_chunks(cur, chunks: list[str], embeddings: list[list[float]], source_path: Path): + """Fügt Chunks mit Embeddings in pgvector ein.""" + now = datetime.now().isoformat() + records = [] + for chunk, embedding in zip(chunks, embeddings): + metadata = { + "id": str(uuid.uuid4()), + "url": f"file://{source_path.resolve()}", + "text": chunk, + "title": source_path.name, + "docSource": "rag-ingestion-script", + "published": now, + "wordCount": len(chunk.split()), + "chunkSource": str(source_path.resolve()), + } + 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)" + ) + + +# --- Haupt-Ingestion --- + +def ingest_file(file_path: str, force: bool = False): + """ + Verarbeitet eine einzelne Datei: + 1. Text lesen + 2. Chunken + 3. Embeddings erstellen + 4. In pgvector speichern (via SSH Tunnel) + """ + path = Path(file_path).resolve() + if not path.exists(): + print(f"❌ Datei nicht gefunden: {path}") + return False + + print(f"\n📄 Verarbeite: {path.name}") + + # Text lesen + try: + text = read_file(path) + print(f" ✅ Text gelesen ({len(text)} Zeichen)") + except Exception as e: + print(f" ❌ Fehler beim Lesen: {e}") + return False + + # Chunken + chunks = chunk_text(text) + print(f" ✅ {len(chunks)} Chunks erstellt") + + # Embeddings + print(f" ⏳ Erstelle Embeddings via OpenAI...") + try: + embeddings = get_embeddings(chunks) + print(f" ✅ {len(embeddings)} Embeddings erstellt") + except Exception as e: + print(f" ❌ Fehler bei Embeddings: {e}") + return False + + # SSH Tunnel + DB + print(f" ⏳ Verbinde mit Datenbank via SSH Tunnel...") + try: + 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_db_connection(tunnel.local_bind_port) + cur = conn.cursor() + + # Prüfen ob Dokument bereits existiert + existing = check_existing(cur, path.name) + if existing > 0: + if force: + delete_existing(cur, path.name) + else: + print(f" ⚠️ '{path.name}' bereits in DB ({existing} Chunks). Nutze --force zum Überschreiben.") + cur.close() + conn.close() + return False + + # Einfügen + insert_chunks(cur, chunks, embeddings, path) + conn.commit() + cur.close() + conn.close() + print(f" ✅ {len(chunks)} Chunks in pgvector gespeichert") + + except Exception as e: + print(f" ❌ Datenbankfehler: {e}") + return False + + return True + + +def ingest_directory(dir_path: str, force: bool = False): + """Verarbeitet alle MD, TXT und PDF Dateien in einem Verzeichnis.""" + path = Path(dir_path).resolve() + if not path.is_dir(): + print(f"❌ Verzeichnis nicht gefunden: {path}") + return + + files = list(path.glob("*.md")) + list(path.glob("*.txt")) + list(path.glob("*.pdf")) + if not files: + print(f"❌ Keine unterstützten Dateien in: {path}") + return + + print(f"\n📁 Verarbeite {len(files)} Dateien aus: {path}") + success = 0 + for f in files: + if ingest_file(str(f), force=force): + success += 1 + + print(f"\n✅ Fertig: {success}/{len(files)} Dateien erfolgreich importiert.") + + +def list_documents(): + """Zeigt alle Dokumente in der Datenbank an.""" + print(f"\n📋 Dokumente in Namespace '{NAMESPACE}':") + try: + 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_db_connection(tunnel.local_bind_port) + cur = conn.cursor() + cur.execute(""" + SELECT metadata->>'title', COUNT(*), MAX(metadata->>'published') + FROM anythingllm_vectors + WHERE namespace = %s + GROUP BY metadata->>'title' + ORDER BY MAX(metadata->>'published') DESC + """, (NAMESPACE,)) + rows = cur.fetchall() + cur.close() + conn.close() + + if not rows: + print(" Keine Dokumente gefunden.") + else: + print(f" {'Titel':<50} {'Chunks':>6} {'Erstellt'}") + print(" " + "-" * 75) + for title, count, published in rows: + print(f" {(title or 'Unknown'):<50} {count:>6} {published or '-'}") + print() + except Exception as e: + print(f"❌ Fehler: {e}") + + +# --- CLI --- + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="RAG Ingestion Script – Dokumente in pgvector importieren" + ) + subparsers = parser.add_subparsers(dest="command") + + # ingest file + p_file = subparsers.add_parser("file", help="Einzelne Datei importieren") + p_file.add_argument("path", help="Pfad zur Datei (MD, TXT, PDF)") + p_file.add_argument("--force", action="store_true", help="Bestehende Chunks überschreiben") + + # ingest directory + p_dir = subparsers.add_parser("dir", help="Verzeichnis importieren") + p_dir.add_argument("path", help="Pfad zum Verzeichnis") + p_dir.add_argument("--force", action="store_true", help="Bestehende Chunks überschreiben") + + # list + subparsers.add_parser("list", help="Alle Dokumente in der DB anzeigen") + + args = parser.parse_args() + + if args.command == "file": + ingest_file(args.path, force=args.force) + elif args.command == "dir": + ingest_directory(args.path, force=args.force) + elif args.command == "list": + list_documents() + else: + parser.print_help() diff --git a/ingest_job_matching.py b/ingest_job_matching.py new file mode 100644 index 0000000..151f610 --- /dev/null +++ b/ingest_job_matching.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +""" +RAG Ingestion Script für Job-Matching Workflow +Liest MD/TXT/PDF Dokumente, chunked sie und speichert Embeddings +in der lokalen pgvector Instanz auf der NAS (Port 5433). + +Verwendung: + python ingest_job_matching.py file # Einzelne Datei + python ingest_job_matching.py dir # Verzeichnis + python ingest_job_matching.py list # Alle Dokumente anzeigen + python ingest_job_matching.py clear # Dokument löschen +""" + +import os +import uuid +from datetime import datetime +from pathlib import Path +from dotenv import load_dotenv +from openai import OpenAI +import psycopg2 +from psycopg2.extras import execute_values +import tiktoken + +# PDF Support (optional) +try: + from pypdf import PdfReader + PDF_SUPPORT = True +except ImportError: + PDF_SUPPORT = False + +# Lade job_matching spezifische .env Datei +env_path = Path(__file__).parent / ".env.job_matching" +load_dotenv(dotenv_path=env_path) + +# --- Konfiguration --- +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +DB_HOST = os.getenv("JM_DB_HOST", "192.168.178.128") +DB_PORT = int(os.getenv("JM_DB_PORT", 5433)) +DB_NAME = os.getenv("JM_DB_NAME", "job_matching") +DB_USER = os.getenv("JM_DB_USER", "jobmatch") +DB_PASSWORD = os.getenv("JM_DB_PASSWORD") +CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", 500)) +CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", 50)) +EMBED_MODEL = "text-embedding-3-small" +EMBED_DIM = 1536 + +client = OpenAI(api_key=OPENAI_API_KEY) + + +# --- Datenbank --- + +def get_db_connection(): + """Direkte Verbindung zur pgvector Instanz auf der NAS.""" + return psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + + +def ensure_table(conn): + """Stellt sicher dass die profile_documents Tabelle existiert.""" + with conn.cursor() as cur: + cur.execute("CREATE EXTENSION IF NOT EXISTS vector;") + cur.execute(f""" + CREATE TABLE IF NOT EXISTS profile_documents ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + content TEXT NOT NULL, + embedding vector({EMBED_DIM}), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(filename, chunk_index) + ); + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS profile_documents_embedding_idx + ON profile_documents + USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 10); + """) + conn.commit() + print(" ✅ Tabelle profile_documents bereit") + + +# --- Text-Extraktion --- + +def read_file(path: Path) -> str: + """Liest MD, TXT oder PDF und gibt den Text zurück.""" + suffix = path.suffix.lower() + if suffix in (".md", ".txt"): + return path.read_text(encoding="utf-8") + elif suffix == ".pdf": + if not PDF_SUPPORT: + raise ImportError("pypdf nicht installiert: pip install pypdf") + reader = PdfReader(str(path)) + return "\n\n".join(page.extract_text() or "" for page in reader.pages) + else: + raise ValueError(f"Nicht unterstütztes Dateiformat: {suffix}") + + +# --- Chunking --- + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]: + """Teilt Text in überlappende Chunks auf (token-basiert).""" + enc = tiktoken.get_encoding("cl100k_base") + tokens = enc.encode(text) + chunks = [] + start = 0 + while start < len(tokens): + end = min(start + chunk_size, len(tokens)) + chunk_tokens = tokens[start:end] + chunks.append(enc.decode(chunk_tokens)) + start += chunk_size - overlap + return chunks + + +# --- Embeddings --- + +def get_embeddings(texts: list[str]) -> list[list[float]]: + """Erstellt Embeddings via OpenAI text-embedding-3-small.""" + response = client.embeddings.create( + model=EMBED_MODEL, + input=texts + ) + return [item.embedding for item in response.data] + + +# --- Ingestion --- + +def check_existing(conn, filename: str) -> int: + """Prüft ob Dokument bereits in der DB vorhanden ist.""" + with conn.cursor() as cur: + cur.execute( + "SELECT COUNT(*) FROM profile_documents WHERE filename = %s", + (filename,) + ) + return cur.fetchone()[0] + + +def delete_existing(conn, filename: str): + """Löscht alle Chunks eines Dokuments.""" + with conn.cursor() as cur: + cur.execute( + "DELETE FROM profile_documents WHERE filename = %s", + (filename,) + ) + conn.commit() + print(f" 🗑️ Bestehende Chunks für '{filename}' gelöscht.") + + +def insert_chunks(conn, chunks: list[str], embeddings: list[list[float]], filename: str): + """Fügt Chunks mit Embeddings in pgvector ein.""" + records = [ + (filename, i, chunk, embedding) + for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)) + ] + with conn.cursor() as cur: + execute_values( + cur, + """ + INSERT INTO profile_documents (filename, chunk_index, content, embedding) + VALUES %s + ON CONFLICT (filename, chunk_index) DO UPDATE + SET content = EXCLUDED.content, + embedding = EXCLUDED.embedding, + created_at = NOW() + """, + records, + template="(%s, %s, %s, %s::vector)" + ) + conn.commit() + + +def ingest_file(file_path: str, force: bool = False) -> bool: + """Verarbeitet eine einzelne Datei.""" + path = Path(file_path).resolve() + if not path.exists(): + print(f"❌ Datei nicht gefunden: {path}") + return False + + print(f"\n📄 Verarbeite: {path.name}") + + # Text lesen + try: + text = read_file(path) + print(f" ✅ Text gelesen ({len(text)} Zeichen)") + except Exception as e: + print(f" ❌ Fehler beim Lesen: {e}") + return False + + # Chunken + chunks = chunk_text(text) + print(f" ✅ {len(chunks)} Chunks erstellt") + + # Embeddings + print(f" ⏳ Erstelle Embeddings via OpenAI ({EMBED_MODEL})...") + try: + embeddings = [] + batch_size = 100 + for i in range(0, len(chunks), batch_size): + batch = chunks[i:i + batch_size] + embeddings.extend(get_embeddings(batch)) + print(f" ✅ {len(embeddings)} Embeddings erstellt") + except Exception as e: + print(f" ❌ Fehler bei Embeddings: {e}") + return False + + # Datenbank + print(f" ⏳ Verbinde mit pgvector auf {DB_HOST}:{DB_PORT}...") + try: + conn = get_db_connection() + ensure_table(conn) + + existing = check_existing(conn, path.name) + if existing > 0: + if force: + delete_existing(conn, path.name) + else: + print(f" ⚠️ '{path.name}' bereits in DB ({existing} Chunks). Nutze --force zum Überschreiben.") + conn.close() + return False + + insert_chunks(conn, chunks, embeddings, path.name) + conn.close() + print(f" ✅ {len(chunks)} Chunks in pgvector gespeichert") + + except Exception as e: + print(f" ❌ Datenbankfehler: {e}") + return False + + return True + + +def ingest_directory(dir_path: str, force: bool = False): + """Verarbeitet alle MD, TXT und PDF Dateien in einem Verzeichnis.""" + path = Path(dir_path).resolve() + if not path.is_dir(): + print(f"❌ Verzeichnis nicht gefunden: {path}") + return + + files = ( + list(path.glob("*.md")) + + list(path.glob("*.txt")) + + list(path.glob("*.pdf")) + ) + files = [f for f in files if not f.name.startswith(".")] + + if not files: + print(f"❌ Keine unterstützten Dateien in: {path}") + return + + print(f"\n📁 Verarbeite {len(files)} Dateien aus: {path}") + success = 0 + for f in sorted(files): + if ingest_file(str(f), force=force): + success += 1 + + print(f"\n✅ Fertig: {success}/{len(files)} Dateien erfolgreich importiert.") + + +def list_documents(): + """Zeigt alle Dokumente in der Datenbank an.""" + print(f"\n📋 Dokumente in profile_documents ({DB_HOST}:{DB_PORT}/{DB_NAME}):") + try: + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute(""" + SELECT filename, COUNT(*) as chunks, MAX(created_at) as imported_at + FROM profile_documents + GROUP BY filename + ORDER BY MAX(created_at) DESC + """) + rows = cur.fetchall() + conn.close() + + if not rows: + print(" Keine Dokumente gefunden.") + else: + print(f"\n {'Dateiname':<55} {'Chunks':>6} {'Importiert'}") + print(" " + "-" * 80) + for filename, chunks, imported_at in rows: + ts = imported_at.strftime("%d.%m.%Y %H:%M") if imported_at else "-" + print(f" {filename:<55} {chunks:>6} {ts}") + print() + except Exception as e: + print(f"❌ Fehler: {e}") + + +def clear_document(filename: str): + """Löscht ein Dokument aus der Datenbank.""" + try: + conn = get_db_connection() + existing = check_existing(conn, filename) + if existing == 0: + print(f"⚠️ '{filename}' nicht in der Datenbank gefunden.") + else: + delete_existing(conn, filename) + print(f"✅ '{filename}' gelöscht ({existing} Chunks).") + conn.close() + except Exception as e: + print(f"❌ Fehler: {e}") + + +# --- CLI --- + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Job-Matching RAG Ingestion – Profil-Dokumente in pgvector importieren" + ) + subparsers = parser.add_subparsers(dest="command") + + p_file = subparsers.add_parser("file", help="Einzelne Datei importieren") + p_file.add_argument("path", help="Pfad zur Datei (MD, TXT, PDF)") + p_file.add_argument("--force", action="store_true", help="Bestehende Chunks überschreiben") + + p_dir = subparsers.add_parser("dir", help="Verzeichnis importieren") + p_dir.add_argument("path", help="Pfad zum Verzeichnis") + p_dir.add_argument("--force", action="store_true", help="Bestehende Chunks überschreiben") + + subparsers.add_parser("list", help="Alle Dokumente in der DB anzeigen") + + p_clear = subparsers.add_parser("clear", help="Dokument aus DB löschen") + p_clear.add_argument("filename", help="Dateiname (z.B. 'Lebenslauf - 2026 - Sebastian Fröhlich.md')") + + args = parser.parse_args() + + if args.command == "file": + ingest_file(args.path, force=args.force) + elif args.command == "dir": + ingest_directory(args.path, force=args.force) + elif args.command == "list": + list_documents() + elif args.command == "clear": + clear_document(args.filename) + else: + parser.print_help() diff --git a/templates/Karriereziele_TEMPLATE.md b/templates/Karriereziele_TEMPLATE.md new file mode 100644 index 0000000..71e5af8 --- /dev/null +++ b/templates/Karriereziele_TEMPLATE.md @@ -0,0 +1,29 @@ +# Karriereziele + +## Kurzfristig (nächste 1–2 Jahre) +- **Zielrolle:** [z.B. Senior Fullstack-Entwickler / Tech Lead] +- **Zielbranche:** [z.B. Fintech, SaaS, KI/ML] +- **Fokus:** [z.B. "Vertiefung in Cloud-Architektur und KI-Integration"] + +## Mittelfristig (3–5 Jahre) +- **Zielrolle:** [z.B. Lead Developer / Engineering Manager / CTO Startup] +- **Fokus:** [z.B. "Technische Führung eines Teams, Architekturentscheidungen"] + +## Langfristig +- **Vision:** [z.B. "Eigenes Produkt aufbauen" / "Technische Beratung" / "Führungsposition"] + +## Was ich suche +- [z.B. Technisch anspruchsvolle Projekte] +- [z.B. Moderner Tech-Stack] +- [z.B. Ein Team, in dem ich wachsen kann] +- [z.B. Möglichkeit, KI/ML in Produkte zu integrieren] + +## Was ich NICHT suche +- [z.B. Reine Wartung von Legacy-Systemen] +- [z.B. 100% Vor-Ort ohne Remote-Option] +- [z.B. Reiner Management-Job ohne Coding] + +## Branchen-Interesse +- **Sehr interessiert:** [z.B. KI/ML, Fintech, Developer Tools] +- **Interessiert:** [z.B. E-Commerce, SaaS, Healthtech] +- **Weniger interessiert:** [z.B. Rüstung, Glücksspiel] diff --git a/templates/Muster-Anschreiben_TEMPLATE.md b/templates/Muster-Anschreiben_TEMPLATE.md new file mode 100644 index 0000000..45a8d36 --- /dev/null +++ b/templates/Muster-Anschreiben_TEMPLATE.md @@ -0,0 +1,30 @@ +# Muster-Anschreiben + +## Angaben zur Bewerbung +- **Zielposition:** [z.B. Senior Fullstack-Entwickler] +- **Unternehmen:** [Name] +- **Branche:** [z.B. Finanzwesen, E-Commerce] +- **Stellenreferenz:** [z.B. Job-ID oder Link] +- **Datum:** [TT.MM.YYYY] + +## Anschreiben + +Sehr geehrte(r) [Frau/Herr Nachname], + +### Einstieg / Bezug zur Stelle +[1-2 Sätze: Warum diese Stelle? Was hat dich angesprochen? Bezug zum Unternehmen.] + +### Qualifikation & Erfahrung +[2-3 Sätze: Relevante Erfahrung, die zur Stelle passt. Konkrete Projekte oder Erfolge nennen.] + +### Motivation & Mehrwert +[2-3 Sätze: Was motiviert dich? Welchen Mehrwert bringst du dem Unternehmen?] + +### Rahmenbedingungen +[1 Satz: Verfügbarkeit, Gehaltsvorstellung falls gefordert.] + +### Abschluss +Ich freue mich auf ein persönliches Gespräch und stehe Ihnen gerne für Rückfragen zur Verfügung. + +Mit freundlichen Grüßen +Sebastian Fröhlich diff --git a/templates/Projektbeschreibung_TEMPLATE.md b/templates/Projektbeschreibung_TEMPLATE.md new file mode 100644 index 0000000..5d22263 --- /dev/null +++ b/templates/Projektbeschreibung_TEMPLATE.md @@ -0,0 +1,31 @@ +# Projektbeschreibung: [Projektname] + +## Überblick +- **Kunde/Arbeitgeber:** [Name] +- **Branche:** [z.B. Finanzwesen, Automotive, Energie] +- **Zeitraum:** [MM/YYYY] – [MM/YYYY] +- **Rolle:** [z.B. Fullstack-Entwickler, Tech Lead, DevOps Engineer] +- **Teamgröße:** [z.B. 5 Entwickler, 2 Designer] + +## Projektbeschreibung +[2-4 Sätze: Was war das Ziel des Projekts? Welches Problem wurde gelöst? Für wen?] + +## Aufgaben & Verantwortlichkeiten +- [Aufgabe 1, z.B. "Konzeption und Umsetzung der REST-API"] +- [Aufgabe 2, z.B. "Migration der bestehenden Datenbank auf PostgreSQL"] +- [Aufgabe 3] + +## Technologien +- **Sprachen:** [z.B. Python, TypeScript, Java] +- **Frameworks:** [z.B. React, Spring Boot, FastAPI] +- **Datenbanken:** [z.B. PostgreSQL, MongoDB, Redis] +- **Infrastruktur:** [z.B. Docker, Kubernetes, AWS, Hetzner] +- **Tools:** [z.B. Git, Jira, Jenkins, GitHub Actions] + +## Ergebnisse & Erfolge +- [Konkretes Ergebnis, z.B. "Ladezeit um 60% reduziert"] +- [Konkretes Ergebnis, z.B. "System verarbeitet 10.000 Anfragen/Min"] +- [Konkretes Ergebnis, z.B. "Erfolgreicher Go-Live mit 500 Nutzern"] + +## Besonderheiten / Lessons Learned +[Optional: Was war besonders herausfordernd? Was hast du gelernt?] diff --git a/templates/Rahmenbedingungen_TEMPLATE.md b/templates/Rahmenbedingungen_TEMPLATE.md new file mode 100644 index 0000000..f4a6b4a --- /dev/null +++ b/templates/Rahmenbedingungen_TEMPLATE.md @@ -0,0 +1,26 @@ +# Rahmenbedingungen + +## Verfügbarkeit +- **Frühester Starttermin:** [z.B. sofort / 01.05.2026 / 3 Monate Kündigungsfrist] +- **Arbeitsumfang:** [z.B. Vollzeit / Teilzeit ab 30h / Projektbasis] + +## Arbeitsmodell +- **Bevorzugt:** [z.B. Remote / Hybrid / Vor-Ort] +- **Remote-Anteil:** [z.B. 100% Remote gewünscht / mind. 3 Tage Remote] +- **Büro-Standorte möglich:** [z.B. Frankfurt, Darmstadt, Rhein-Main-Gebiet] +- **Reisebereitschaft:** [z.B. gelegentlich / bis 20% / nein] +- **Umzugsbereitschaft:** [z.B. ja, innerhalb Deutschlands / nein] + +## Gehaltsvorstellung +- **Festanstellung:** [z.B. 75.000–85.000 EUR brutto/Jahr] +- **Freiberuflich:** [z.B. 90–110 EUR/h] +- **Verhandlungsbasis:** [z.B. abhängig vom Gesamtpaket / Benefits] + +## Vertragsform +- **Bevorzugt:** [z.B. Festanstellung / Freelance / beides möglich] +- **Befristung:** [z.B. unbefristet bevorzugt / Projektbasis ok] + +## Sonstiges +- **Führerschein:** [z.B. Klasse B vorhanden] +- **Sprachen:** [z.B. Deutsch (Muttersprache), Englisch (fließend)] +- **Besondere Anforderungen:** [z.B. keine Sicherheitsüberprüfung nötig / SÜ2 vorhanden] diff --git a/templates/Referenzen_TEMPLATE.md b/templates/Referenzen_TEMPLATE.md new file mode 100644 index 0000000..aaf269b --- /dev/null +++ b/templates/Referenzen_TEMPLATE.md @@ -0,0 +1,25 @@ +# Referenzen + +## Referenz 1 +- **Name:** [Vor- und Nachname] +- **Position:** [z.B. Teamleiter Softwareentwicklung] +- **Unternehmen:** [Name] +- **Verhältnis:** [z.B. Direkter Vorgesetzter, 2021–2026] +- **Kontakt:** [auf Anfrage / E-Mail / Telefon] +- **Kontext:** [z.B. "Hat meine Arbeit als Fullstack-Entwickler im Banking-Projekt begleitet"] + +## Referenz 2 +- **Name:** [Vor- und Nachname] +- **Position:** [z.B. Projektmanager] +- **Unternehmen:** [Name] +- **Verhältnis:** [z.B. Projektleiter im gemeinsamen Kundenprojekt] +- **Kontakt:** [auf Anfrage / E-Mail / Telefon] +- **Kontext:** [z.B. "Zusammenarbeit im Bereich Mobile App Entwicklung"] + +## Referenz 3 +- **Name:** [Vor- und Nachname] +- **Position:** [Position] +- **Unternehmen:** [Name] +- **Verhältnis:** [Beziehung] +- **Kontakt:** [auf Anfrage] +- **Kontext:** [Kurzbeschreibung der Zusammenarbeit] diff --git a/templates/Technologie-Bewertung_TEMPLATE.md b/templates/Technologie-Bewertung_TEMPLATE.md new file mode 100644 index 0000000..4ff2a26 --- /dev/null +++ b/templates/Technologie-Bewertung_TEMPLATE.md @@ -0,0 +1,64 @@ +# Technologie-Bewertung / Skill-Matrix + +## Legende +- **Erfahrungslevel:** Grundlagen | Solide | Fortgeschritten | Experte +- **Letzte Nutzung:** Aktuell | < 1 Jahr | 1–3 Jahre | > 3 Jahre +- **Kontext:** Beruflich (B), Privat (P), Weiterbildung (W) + +## Programmiersprachen + +| Technologie | Level | Jahre | Letzte Nutzung | Kontext | Anmerkung | +|---|---|---|---|---|---| +| [z.B. TypeScript] | [Fortgeschritten] | [5] | [Aktuell] | [B, P] | [z.B. "Hauptsprache seit 2021"] | +| [z.B. Python] | [Solide] | [3] | [Aktuell] | [B, P] | [z.B. "RAG, Scripting, APIs"] | +| [z.B. Java] | [Fortgeschritten] | [8] | [< 1 Jahr] | [B] | [z.B. "Spring Boot Projekte"] | + +## Frontend + +| Technologie | Level | Jahre | Letzte Nutzung | Kontext | Anmerkung | +|---|---|---|---|---|---| +| [z.B. React] | [Fortgeschritten] | [4] | [Aktuell] | [B, P] | | +| [z.B. HTML/CSS] | [Experte] | [12] | [Aktuell] | [B, P] | | +| [z.B. Tailwind CSS] | [Solide] | [2] | [Aktuell] | [P] | | + +## Backend / Frameworks + +| Technologie | Level | Jahre | Letzte Nutzung | Kontext | Anmerkung | +|---|---|---|---|---|---| +| [z.B. Node.js/Express] | [Fortgeschritten] | [5] | [Aktuell] | [B] | | +| [z.B. Spring Boot] | [Fortgeschritten] | [6] | [< 1 Jahr] | [B] | | +| [z.B. FastAPI] | [Solide] | [1] | [Aktuell] | [P] | | + +## Datenbanken + +| Technologie | Level | Jahre | Letzte Nutzung | Kontext | Anmerkung | +|---|---|---|---|---|---| +| [z.B. PostgreSQL] | [Fortgeschritten] | [6] | [Aktuell] | [B, P] | [inkl. pgvector] | +| [z.B. MongoDB] | [Solide] | [3] | [1–3 Jahre] | [B] | | +| [z.B. Redis] | [Grundlagen] | [1] | [< 1 Jahr] | [B] | | + +## DevOps / Infrastruktur + +| Technologie | Level | Jahre | Letzte Nutzung | Kontext | Anmerkung | +|---|---|---|---|---|---| +| [z.B. Docker] | [Fortgeschritten] | [4] | [Aktuell] | [B, P] | | +| [z.B. Linux/Bash] | [Fortgeschritten] | [10] | [Aktuell] | [B, P] | | +| [z.B. GitHub Actions] | [Solide] | [2] | [Aktuell] | [B, P] | | +| [z.B. AWS] | [Grundlagen] | [1] | [1–3 Jahre] | [W] | | + +## KI / Machine Learning + +| Technologie | Level | Jahre | Letzte Nutzung | Kontext | Anmerkung | +|---|---|---|---|---|---| +| [z.B. OpenAI API] | [Solide] | [2] | [Aktuell] | [B, P] | | +| [z.B. RAG / Embeddings] | [Solide] | [1] | [Aktuell] | [P] | [apply4jobs.de] | +| [z.B. LangChain] | [Grundlagen] | [< 1] | [Aktuell] | [W] | | + +## Methoden & Practices + +| Thema | Level | Anmerkung | +|---|---|---| +| [z.B. Agile/Scrum] | [Fortgeschritten] | [z.B. "6 Jahre in Scrum-Teams"] | +| [z.B. Git/Git-Flow] | [Experte] | | +| [z.B. Code Review] | [Fortgeschritten] | | +| [z.B. TDD] | [Solide] | | diff --git a/templates/Ueber-mich_Elevator-Pitch_TEMPLATE.md b/templates/Ueber-mich_Elevator-Pitch_TEMPLATE.md new file mode 100644 index 0000000..d4e9d95 --- /dev/null +++ b/templates/Ueber-mich_Elevator-Pitch_TEMPLATE.md @@ -0,0 +1,21 @@ +# Über mich / Elevator Pitch + +## Kurzvorstellung (1 Satz) +[z.B. "Erfahrener Fullstack-Entwickler mit 12+ Jahren Berufserfahrung in den Bereichen Fintech, Automotive und Mobile Development."] + +## Mittlere Version (3 Sätze) +[z.B. "Ich bin Sebastian Fröhlich, Fullstack-Entwickler mit über 12 Jahren Erfahrung in der Softwareentwicklung. Meine Schwerpunkte liegen in [Technologien/Bereichen]. Aktuell suche ich eine neue Herausforderung im Bereich [Zielbereich]."] + +## Ausführliche Version (1 Absatz) +[z.B. "Als leidenschaftlicher Softwareentwickler bringe ich über 12 Jahre Erfahrung aus verschiedenen Branchen mit — von Automotive über Energie bis hin zu Fintech. Bei [letzter Arbeitgeber] habe ich [wichtigstes Projekt/Ergebnis]. Besonders stark bin ich in [Top-Skills]. Was mich antreibt, ist [Motivation]. Für meine nächste Station suche ich [was du suchst]."] + +## Persönliche Werte & Arbeitsweise +- [z.B. Qualitätsbewusst — sauberer Code ist mir wichtig] +- [z.B. Teamplayer — ich teile Wissen gerne und lerne von anderen] +- [z.B. Pragmatisch — funktionierende Lösungen vor Over-Engineering] +- [z.B. Lernbereit — ich bilde mich ständig weiter] + +## Was mich von anderen unterscheidet +- [z.B. Breite Branchenerfahrung: Automotive, Energie, Fintech] +- [z.B. Fullstack-Profil: Von der Datenbank bis zum Frontend] +- [z.B. Eigene Projekte: apply4jobs.de als Beispiel für Eigeninitiative] diff --git a/templates/Zertifikate_Weiterbildungen_TEMPLATE.md b/templates/Zertifikate_Weiterbildungen_TEMPLATE.md new file mode 100644 index 0000000..6a88a76 --- /dev/null +++ b/templates/Zertifikate_Weiterbildungen_TEMPLATE.md @@ -0,0 +1,25 @@ +# Zertifikate & Weiterbildungen + +## Zertifikate + +| Zertifikat | Anbieter | Datum | Gültigkeit | Nachweis | +|---|---|---|---|---| +| [z.B. AWS Solutions Architect] | [z.B. Amazon] | [MM/YYYY] | [unbefristet / bis MM/YYYY] | [Link/Datei] | +| [z.B. Professional Scrum Master I] | [z.B. Scrum.org] | [MM/YYYY] | [unbefristet] | [Link/Datei] | + +## Weiterbildungen / Kurse + +| Kurs | Plattform | Zeitraum | Umfang | Themengebiet | +|---|---|---|---|---| +| [z.B. Machine Learning Specialization] | [z.B. Coursera] | [MM/YYYY] | [z.B. 40h] | [z.B. KI/ML] | +| [z.B. Docker & Kubernetes Masterclass] | [z.B. Udemy] | [MM/YYYY] | [z.B. 20h] | [z.B. DevOps] | + +## Konferenzen & Meetups + +- [z.B. WeAreDevelopers 2025, Berlin — Teilnehmer] +- [z.B. React Meetup Frankfurt — regelmäßiger Teilnehmer seit 2023] + +## Geplante Weiterbildungen + +- [z.B. Kubernetes CKA Zertifizierung — geplant Q2 2026] +- [z.B. Rust Grundlagen — in Arbeit] diff --git a/templates/Zielstellen-Profil_TEMPLATE.md b/templates/Zielstellen-Profil_TEMPLATE.md new file mode 100644 index 0000000..20e2324 --- /dev/null +++ b/templates/Zielstellen-Profil_TEMPLATE.md @@ -0,0 +1,35 @@ +# Zielstellen-Profil + +## Ideale Stellenbezeichnungen +- [z.B. Senior Fullstack Developer] +- [z.B. Lead Software Engineer] +- [z.B. Backend Developer (Python/Node.js)] + +## Must-Have Kriterien +- [z.B. Remote-Option (mind. 80%)] +- [z.B. Moderner Tech-Stack (kein COBOL/SAP)] +- [z.B. Agile Arbeitsweise] +- [z.B. Unbefristeter Vertrag] + +## Nice-to-Have Kriterien +- [z.B. Startup-/Scale-up-Kultur] +- [z.B. Open-Source-Beteiligung] +- [z.B. Weiterbildungsbudget] +- [z.B. 4-Tage-Woche / flexible Arbeitszeiten] + +## Relevante Keywords in Stellenausschreibungen +- [z.B. React, TypeScript, Node.js, Python, PostgreSQL] +- [z.B. Docker, Kubernetes, CI/CD, AWS/GCP] +- [z.B. REST API, Microservices, Event-Driven] +- [z.B. Agile, Scrum, Kanban] + +## Ausschlusskriterien (Red Flags) +- [z.B. "Rockstar Developer" / toxische Kultur-Signale] +- [z.B. Keine Remote-Option] +- [z.B. Veralteter Tech-Stack ohne Modernisierungsplan] +- [z.B. Unbezahlte Überstunden erwartet] + +## Unternehmensgröße +- **Bevorzugt:** [z.B. 20–200 Mitarbeiter / Scale-up] +- **Auch ok:** [z.B. Konzern mit autonomen Teams] +- **Weniger passend:** [z.B. Einzelkämpfer-Rolle in Kleinstunternehmen]