From 61faa9fcee9db0c9c08acad01931f18832830c67 Mon Sep 17 00:00:00 2001 From: Homelab Cursor Date: Fri, 17 Apr 2026 21:25:42 +0200 Subject: [PATCH] RAG Multi-Query DE/EN + OpenAI-Key erneuert - tools/rag.py: _is_count_list_query + _expand_multilingual fuer Zaehlfragen (z.B. >meine 2 Wohnungen in Kambodscha< findet jetzt beide Units) - homelab.conf: OPENAI_API_KEY erneuert (alter widerrufen) - Cleanup: Backup-Files entfernt --- homelab-ai-bot/tools/rag.py | 93 ++++ homelab-ai-bot/tools/rag.py.bak-multiquery | 501 --------------------- homelab.conf | 2 +- homelab.conf.bak-20260417-211337 | 310 ------------- 4 files changed, 94 insertions(+), 812 deletions(-) delete mode 100644 homelab-ai-bot/tools/rag.py.bak-multiquery delete mode 100644 homelab.conf.bak-20260417-211337 diff --git a/homelab-ai-bot/tools/rag.py b/homelab-ai-bot/tools/rag.py index 4fd33016..9fd62c10 100644 --- a/homelab-ai-bot/tools/rag.py +++ b/homelab-ai-bot/tools/rag.py @@ -391,6 +391,81 @@ def _merge_hits_from_queries( return merged[:pool_cap], last_err + + +# --- Multi-Query-Erweiterung (2026-04-17) --------------------------------- +_COUNT_LIST_RE = re.compile( + r"\b(meine|alle|welche|wieviel(e)?|wie\s*viel(e)?|liste|übersicht|uebersicht|" + r"überblick|ueberblick|gesamt)\b.{0,40}" + r"\b(wohnung|apartment|condo|vertr[aä]g|rechnung|konto|konten|auto|fahrzeug|" + r"haus|immobilie|property|reise|flug|rente|arbeitgeber|kunde|lieferant|" + r"gebäude|grundstück)", + re.IGNORECASE, +) +_NUMERIC_COUNT_RE = re.compile( + r"\b(\d+|zwei|drei|vier|fünf|sechs|sieben|acht)\s+" + r"(wohnung|apartment|condo|vertr[aä]g|auto|fahrzeug|haus|immobilie|property|" + r"rechnung|flug|reise|gebäude|grundstück)", + re.IGNORECASE, +) +_TRANSLATE_PAIRS = [ + ("wohnungen", ["apartments", "condos", "units"]), + ("wohnung", ["apartment", "condo", "unit"]), + ("kambodscha", ["cambodia"]), + ("kaufvertrag", ["purchase agreement", "sale agreement"]), + ("kaufverträge", ["purchase agreements", "sale agreements"]), + ("mietvertrag", ["lease", "rental agreement"]), + ("verträge", ["contracts", "agreements"]), + ("vertrag", ["contract", "agreement"]), + ("rechnungen", ["invoices", "bills"]), + ("rechnung", ["invoice", "bill"]), + ("versicherungen", ["insurances", "policies"]), + ("versicherung", ["insurance", "policy"]), + ("haus", ["house", "building"]), + ("fahrzeug", ["vehicle", "car"]), + ("auto", ["car", "vehicle"]), + ("reise", ["trip", "journey"]), + ("flug", ["flight"]), + ("immobilie", ["property", "real estate"]), + ("grundstück", ["plot", "land", "property"]), + ("gebäude", ["building"]), + ("überweisung", ["transfer", "payment"]), + ("zahlung", ["payment"]), + ("krankenversicherung", ["health insurance"]), + ("lebensversicherung", ["life insurance"]), +] + +def _is_count_list_query(q: str) -> bool: + """Zähl-/Listen-/Vollständigkeitsfragen (sprachübergreifend).""" + if not q: + return False + ql = q.lower() + if _COUNT_LIST_RE.search(ql): + return True + if _NUMERIC_COUNT_RE.search(ql): + return True + return False + +def _expand_multilingual(q: str) -> list: + """Erzeugt DE/EN-Varianten per Term-Substitution (max 8).""" + base = (q or "").strip() + if not base: + return [] + ql = base.lower() + variants: list = [base] + seen = {base} + for de, ens in _TRANSLATE_PAIRS: + if de in ql: + for en in ens: + v = re.sub(re.escape(de), en, ql, flags=re.IGNORECASE) + if v and v not in seen: + variants.append(v) + seen.add(v) + if len(variants) >= 8: + break + return variants[:8] +# --- /Multi-Query-Erweiterung --------------------------------------------- + def handle_rag_search(query: str, top_k: int = 8, **kw): if not query or not query.strip(): return "rag_search: query fehlt." @@ -420,6 +495,24 @@ def handle_rag_search(query: str, top_k: int = 8, **kw): f"{len(hits)} Kandidaten, zeige bis {top_k}:**\n" ) snip_len = 400 + elif _is_count_list_query(qstrip): + # 2026-04-17: Zaehl-/Listen-Fragen (z.B. "meine 2 Wohnungen in Kambodscha") + subqs = _expand_multilingual(qstrip) + top_k = max(top_k, 10) + pool_cap = max(top_k * 5, 100) + hits, err = _merge_hits_from_queries( + subqs, + es_size, + pool_cap=pool_cap, + full_path_dedup=True, + ) + if err and not hits: + return f"Fehler bei der Dokumentensuche: {err}" + header = ( + f"**Multi-Query ({len(subqs)} Varianten DE+EN) fuer '{qstrip}' — " + f"{len(hits)} Kandidaten, zeige bis {top_k}:**\n" + ) + snip_len = 500 else: data = _es_hybrid_search(qstrip, es_size) if "_error" in data: diff --git a/homelab-ai-bot/tools/rag.py.bak-multiquery b/homelab-ai-bot/tools/rag.py.bak-multiquery deleted file mode 100644 index 4fd33016..00000000 --- a/homelab-ai-bot/tools/rag.py.bak-multiquery +++ /dev/null @@ -1,501 +0,0 @@ -"""RAG Dokumentensuche — Elasticsearch direkt (Hybrid: kNN + deutscher Text). - -RAGFlow bleibt Ingestion; Suche geht direkt an ES (Issue #51). -""" - -import base64 -import json -import logging -import re -import urllib.error -import urllib.request - -log = logging.getLogger("tools.rag") - -ES_BASE = "http://100.109.101.12:1200" -ES_USER = "elastic" -ES_PASS = "infini_rag_flow" -ES_INDEX = "ragflow_61f51c8c279011f1a174bd19863ba33e" -KB_ID = "dc24edda27a311f19fe7fb811de6f016" -OLLAMA_EMBED_URL = "http://100.84.255.83:11434/api/embeddings" -EMBED_MODEL = "nomic-embed-text" - -# Cross-Encoder Reranking (CT 123, pve-hetzner LAN) -RERANKER_URL = "http://10.10.10.123:8099" -RERANK_CANDIDATES = 15 -RERANK_TIMEOUT = 45 -RERANK_SNIPPET_CHARS = 512 - -MIN_TOP_K = 5 -# Breite Übersichten: mehr ES-Runden, mehr distinct Treffer (pro vollem Pfad docnm_kwd) -MAX_TOP_K_NORMAL = 25 -MAX_TOP_K_WIDE = 60 -ES_SIZE_CAP = 200 - -TOOLS = [ - { - "type": "function", - "function": { - "name": "rag_search", - "description": ( - "Durchsucht die private Dokumenten-Wissensbasis (>21.000 Dokumente: " - "Vertraege, Versicherungen, Rente, Finanzamt, Familiendokumente, " - "Anleitungen, Buecher, persoenliche Unterlagen). " - "Nutze dieses Tool wenn der User nach einem bestimmten Dokument, " - "Vertrag, Brief oder persoenlicher Information fragt. " - "Bei breiten Fragen ('welche Versicherungen', Jahreskosten, Listen) " - "top_k=15 oder hoeher setzen." - ), - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": ( - "Suchanfrage: Dokumentname, Thema oder Inhalt. Kurz und praezise, " - "z.B. 'Familienbuch Opa Oma' oder 'Grundsteuer Erklaerung 2024'" - ), - }, - "top_k": { - "type": "integer", - "description": "Anzahl Ergebnisse (5-25, Standard 10)", - "default": 10, - }, - }, - "required": ["query"], - }, - }, - }, -] - -SYSTEM_PROMPT_EXTRA = """RAG DOKUMENTENSUCHE — PFLICHT-REGELN: -Du hast Zugriff auf eine private Wissensbasis mit >21.000 Dokumenten (Vertraege, Versicherungen, Rente, Finanzamt, Familiendokumente, Anleitungen, Buecher, persoenliche Unterlagen, Arbeitsvertraege, Kindergeld, Reisepass, Personalausweis, KFZ, Mietvertraege, Bausparvertraege, Rechnungen). - -WANN rag_search AUFRUFEN — IMMER bei diesen Fragen: -- "habe ich..." / "gibt es..." / "wo ist..." / "finde..." / "zeig mir..." + Dokument/Vertrag/Versicherung/Bescheid -- Jede Frage nach persoenlichen Unterlagen, Vertraegen, Versicherungen, Rechnungen, Bescheiden -- AUCH wenn du glaubst die Antwort zu kennen — das Gedaechtnis ist NICHT die Wissensbasis! -- AUCH wenn das Thema im Gedaechtnis steht — trotzdem rag_search aufrufen fuer vollstaendige Antwort - -WANN NICHT: Nur bei reinen Homelab/IT-Fragen, Smalltalk, oder wenn der User explizit NICHT nach Dokumenten fragt. - -SUCHANFRAGE: Kurze Keywords, KEINE ganzen Saetze. Beispiele: -- "Familienbuch" / "Grundsteuer Erklaerung" / "Haftpflicht" / "Kindergeld" / "Mietvertrag" / "Arbeitsvertrag" / "Reisepass" - -ERGEBNISSE AUSWERTEN: -- Bei breiten Fragen ("welche Versicherungen", Jahreskosten, Listen): top_k=15-25, ALLE Treffer aus der Tool-Antwort abarbeiten -- Liste die gefundenen Dokumente mit Ordner und kurzem Inhalt auf -- ERFINDE KEINE Details die nicht im Ergebnis stehen -- Der Ordnerpfad (vor dem Dateinamen, getrennt durch __) zeigt die Kategorie -- Wenn rag_search Treffer liefert: IMMER auflisten, auch wenn Inhalt unvollstaendig -- Mehrere Treffer zur gleichen Versicherung/Gesellschaft: jede Sparte/Dokumentart separat nennen (Kfz, Rechtsschutz, Haftpflicht, Sach, Ausland, Kranken), mit Dateiname/Ordner -- Antworte NIEMALS "keine gefunden" oder "nicht gespeichert" OHNE vorher rag_search aufgerufen zu haben""" - - -def _basic_auth_header() -> str: - token = base64.b64encode(f"{ES_USER}:{ES_PASS}".encode()).decode() - return f"Basic {token}" - - -def _ollama_embed(text: str) -> list | None: - body = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode() - req = urllib.request.Request( - OLLAMA_EMBED_URL, - data=body, - method="POST", - headers={"Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(req, timeout=120) as resp: - data = json.load(resp) - emb = data.get("embedding") - if not emb: - return None - if len(emb) != 768: - log.warning("Unexpected embedding dimension %s", len(emb)) - return emb - except Exception as e: - log.error("Ollama embed error: %s", e) - return None - - -def _ocr_note(text: str) -> str: - if not text or len(text) < 40: - return "" - non_alnum = sum(1 for c in text if not c.isalnum() and not c.isspace()) - ratio = non_alnum / max(len(text), 1) - words = re.findall(r"\w+", text, re.UNICODE) - avg_len = (sum(len(w) for w in words) / len(words)) if words else 0.0 - if ratio > 0.15 or avg_len < 2.0: - return " [OCR vermutlich schlecht]" - return "" - - -def _folder_from_docname(name: str) -> str: - """Extrahiert den Ordnerpfad aus docnm_kwd (__ = Trenner).""" - parts = name.rsplit("__", 1) - if len(parts) == 2: - return parts[0].replace("__", " > ").replace("_", " ") - return "" - - -def _dedup_key(name: str) -> str: - """Normalisiert Dokumentnamen fuer Deduplizierung. - - Extrahiert nur den Dateinamen (nach letztem __), ignoriert - Dateiendung und Kopie-Marker wie (1), (2). - 'Ordner__Foo(1).pdf' und 'Anderer__Foo.txt' werden als gleich behandelt. - """ - fname = name.rsplit("__", 1)[-1] if "__" in name else name - key = re.sub(r"\.(pdf|txt|docx?|xlsx?|csv|png|jpg|jpeg)$", "", fname, flags=re.IGNORECASE) - key = re.sub(r"\s*\(\d+\)\s*$", "", key).rstrip() - return key.lower() - - -def _dedup_key_full_doc(name: str) -> str: - """Ein Chunk pro vollem docnm_kwd — gleicher Dateiname in verschiedenen Ordnern bleibt getrennt.""" - return re.sub(r"\s+", " ", (name or "").strip().lower()) - - -def _es_hybrid_search(query: str, es_size: int) -> dict: - qvec = _ollama_embed(query) - if not qvec: - return {"_error": "Embedding fehlgeschlagen (Ollama nicht erreichbar?)."} - - es_size = min(ES_SIZE_CAP, max(es_size, 20)) - kb_filter = {"term": {"kb_id": KB_ID}} - body = { - "size": es_size, - "knn": { - "field": "q_768_vec", - "query_vector": qvec, - "k": es_size, - "num_candidates": min(500, max(es_size * 5, 150)), - "filter": [kb_filter], - }, - "query": { - "bool": { - "filter": [kb_filter], - "should": [ - {"match": {"content_de": {"query": query, "boost": 2.0}}}, - {"match": {"content_ltks": {"query": query.lower(), "boost": 0.4}}}, - {"match": {"docnm_kwd": {"query": query, "boost": 3.0}}}, - ], - "minimum_should_match": 0, - } - }, - } - url = f"{ES_BASE}/{ES_INDEX}/_search" - req = urllib.request.Request( - url, - data=json.dumps(body).encode(), - method="POST", - headers={ - "Content-Type": "application/json", - "Authorization": _basic_auth_header(), - }, - ) - try: - with urllib.request.urlopen(req, timeout=120) as resp: - return json.load(resp) - except urllib.error.HTTPError as e: - err = e.read().decode(errors="replace")[:800] - log.error("ES HTTP %s: %s", e.code, err) - return {"_error": f"ES HTTP {e.code}: {err}"} - except Exception as e: - log.error("ES search error: %s", e) - return {"_error": str(e)} - - -def _snippet_for_rerank(src: dict) -> str: - doc_name = src.get("docnm_kwd") or "" - raw = src.get("content_with_weight") or src.get("content_de") or "" - prefix = doc_name[:120] + "\n" if doc_name else "" - return prefix + raw[:RERANK_SNIPPET_CHARS] - - -def _rerank_hits(query: str, hits: list) -> tuple[list, bool]: - """Rerankt mit Cross-Encoder, kombiniert Score mit ES-Rang (RRF).""" - if not hits or not RERANKER_URL: - return hits, False - to_score = hits[:RERANK_CANDIDATES] - docs = [] - for h in to_score: - src = h.get("_source") or {} - docs.append(_snippet_for_rerank(src)) - if not any((d or "").strip() for d in docs): - return hits, False - body = json.dumps({"query": query, "documents": docs}).encode() - url = f"{RERANKER_URL.rstrip('/')}/rerank" - req = urllib.request.Request( - url, - data=body, - method="POST", - headers={"Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(req, timeout=RERANK_TIMEOUT) as resp: - data = json.load(resp) - scores = data.get("scores") or [] - if len(scores) != len(to_score): - log.warning( - "rerank score count mismatch: %s vs %s", - len(scores), - len(to_score), - ) - return hits, False - - k = 60 - rr_ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) - rr_rank_map = {i: rank + 1 for rank, i in enumerate(rr_ranked)} - - combined: list[tuple[float, int]] = [] - for idx in range(len(to_score)): - es_rank = idx + 1 - rr_rank = rr_rank_map[idx] - rrf = 1.0 / (k + es_rank) + 1.0 / (k + rr_rank) - combined.append((rrf, idx)) - combined.sort(key=lambda x: x[0], reverse=True) - - new_order: list = [] - for rrf, idx in combined: - h = to_score[idx] - h["_rerank_score"] = float(scores[idx]) - h["_rrf_score"] = float(rrf) - new_order.append(h) - rest = hits[RERANK_CANDIDATES:] - return new_order + rest, True - except Exception as e: - log.warning("rerank failed (fallback to ES): %s", e) - return hits, False - - -def _is_wide_recall_query(q: str) -> bool: - """Übersichts-/Listen-/Kostenfragen: mehrfach suchen und mergen.""" - ql = (q or "").lower() - if any(x in ql for x in ("welche versicherung", "alle versicherung", "versicherungen habe")): - return True - if "versicherung" in ql and any( - x in ql - for x in ( - "welche", - "alle", - "liste", - "überblick", - "ueberblick", - "kosten", - "beitrag", - "jähr", - "jaehr", - "jahres", - "gesamt", - "summe", - "übersicht", - "uebersicht", - ) - ): - return True - costish = any( - x in ql - for x in ( - "kosten", - "kostet", - "wie viel", - "wieviel", - "beitrag", - "beiträge", - "beitraege", - "eur", - "euro", - "jähr", - "jaehr", - "jahreskosten", - "prämie", - "praemie", - ) - ) - broad = any( - x in ql - for x in ( - "liste", - "übersicht", - "uebersicht", - "alle", - "gesamt", - "summe", - "jährlich", - "jaehrlich", - ) - ) - return costish and broad - - -# Zusatzanfragen decken Sparten + Gesellschaften ab (Recall) -_WIDE_SUBQUERIES = [ - "Versicherung Beitragsrechnung Jahresbeitrag", - "Wohngebäudeversicherung Gebäude Beitrag", - "Hausratversicherung Beitrag Ergo", - "Haftpflichtversicherung Beitrag GARANTA", - "Kfz Kasko Haftpflicht Beitragsrechnung", - "Rechtsschutzversicherung Beitrag", - "Lebensversicherung Beitrag", - "Krankenversicherung PKV Beitrag", - "Sachversicherung LVM Beitrag", - "LVM AutoPlus Versicherungsschein", - "Allianz Versicherung Police", - "Nürnberger Versicherung Beitrag", - "Ergo Versicherung Police", - "Unfallversicherung Berufsunfähigkeit", - "Bausparvertrag Bauspar", - "Ford Transit Nutzfahrzeug Versicherung", - "Kfz Versicherungsschein Beitrag jährlich", -] - - -def _merge_hits_from_queries( - queries: list[str], - es_size: int, - pool_cap: int, - *, - full_path_dedup: bool = False, -) -> tuple[list, str | None]: - """Führt mehrere Hybrid-Suchen aus; pro Dedup-Key höchster Score.""" - best: dict[str, dict] = {} - last_err: str | None = None - - def dkey(dn: str) -> str: - return _dedup_key_full_doc(dn) if full_path_dedup else _dedup_key(dn) - - def absorb(hits: list) -> None: - for h in hits: - src = h.get("_source") or {} - dn = src.get("docnm_kwd") or "?" - dk = dkey(dn) - sc = float(h.get("_score") or 0.0) - old = best.get(dk) - if old is None or sc > float(old.get("_score") or 0.0): - best[dk] = h - - for q in queries: - q = (q or "").strip() - if not q: - continue - data = _es_hybrid_search(q, es_size) - if "_error" in data: - last_err = str(data["_error"]) - log.warning("wide_recall subquery fail %s: %s", q[:40], last_err) - continue - absorb((data.get("hits") or {}).get("hits") or []) - - merged = sorted(best.values(), key=lambda h: float(h.get("_score") or 0.0), reverse=True) - return merged[:pool_cap], last_err - - -def handle_rag_search(query: str, top_k: int = 8, **kw): - if not query or not query.strip(): - return "rag_search: query fehlt." - - qstrip = query.strip() - wide = _is_wide_recall_query(qstrip) - cap = MAX_TOP_K_WIDE if wide else MAX_TOP_K_NORMAL - top_k = max(MIN_TOP_K, min(int(top_k or 10), cap)) - es_size = min(ES_SIZE_CAP, max(top_k * 10, 70)) - - if wide: - subqs = [qstrip] - for sq in _WIDE_SUBQUERIES: - if sq.lower() not in qstrip.lower(): - subqs.append(sq) - pool_cap = max(top_k * 5, 120) - hits, err = _merge_hits_from_queries( - subqs[:22], - es_size, - pool_cap=pool_cap, - full_path_dedup=True, - ) - if err and not hits: - return f"Fehler bei der Dokumentensuche: {err}" - header = ( - f"**Breitensuche ({len(subqs[:22])} Anfragen, Dedup=voller Pfad) '{qstrip}' — " - f"{len(hits)} Kandidaten, zeige bis {top_k}:**\n" - ) - snip_len = 400 - else: - data = _es_hybrid_search(qstrip, es_size) - if "_error" in data: - return f"Fehler bei der Dokumentensuche: {data['_error']}" - hits = (data.get("hits") or {}).get("hits") or [] - header = f"**Dokumente fuer '{qstrip}' (bis {top_k}):**\n" - snip_len = 650 - - if not hits: - return f"Keine Ergebnisse fuer '{qstrip}' in der Wissensbasis gefunden." - - hits, reranked = _rerank_hits(qstrip, hits) - - seen_docs: set[str] = set() - lines: list[str] = [] - count = 0 - - def out_dkey(doc_name: str) -> str: - return _dedup_key_full_doc(doc_name) if wide else _dedup_key(doc_name) - - for h in hits: - if count >= top_k: - break - src = h.get("_source") or {} - doc_name = src.get("docnm_kwd") or "?" - dk = out_dkey(doc_name) - if dk in seen_docs: - continue - seen_docs.add(dk) - - if "_rrf_score" in h: - score = float(h["_rrf_score"]) - score_label = "RRF" - elif "_rerank_score" in h: - score = float(h["_rerank_score"]) - score_label = "Rerank" - else: - score = float(h.get("_score") or 0.0) - score_label = "ES" - raw = src.get("content_with_weight") or src.get("content_de") or "" - content = raw[:snip_len].strip() - ocr = _ocr_note(raw) - folder = _folder_from_docname(doc_name) - filename = doc_name.rsplit("__", 1)[-1] if "__" in doc_name else doc_name - folder_line = f" Ordner: {folder}" if folder else "" - - lines.append( - f"---\n**{count + 1}. {filename}** ({score_label}: {score:.3f}){ocr}" - ) - if folder_line: - lines.append(folder_line) - if content: - lines.append(f"```\n{content}\n```") - count += 1 - - if count == 0: - return f"Keine Dokumente fuer '{qstrip}' gefunden." - - hdr = header.rstrip() + ( - " _(Cross-Encoder reranked)_" if reranked else "" - ) + "\n" - lines.insert(0, hdr) - tail = ( - "\n---\n(Ende der Ergebnisse. Nur diese Dokumente in dieser Runde. " - + ( - "Bei Summen/Zahlen: alle Treffer prüfen; OCR kann unvollständig sein." - if wide - else "" - ) - + ")" - ) - lines.append(tail) - - return "\n".join(lines) - - -HANDLERS = { - "rag_search": handle_rag_search, -} diff --git a/homelab.conf b/homelab.conf index e6563a44..8e1f1762 100644 --- a/homelab.conf +++ b/homelab.conf @@ -198,7 +198,7 @@ FORGEJO_TOKEN="b874766bdf357bd4c32fa4369d0c588fc6193336" FORGEJO_SYNC_TOKEN="b874766bdf357bd4c32fa4369d0c588fc6193336" GITHUB_PAT="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" OPENROUTER_KEY="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" -OPENAI_API_KEY="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" +OPENAI_API_KEY="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" MEMORY_API_TOKEN="Ai8eeQibV6Z1RWc7oNPim4PXB4vILU1nRW2-XgRcX2M" MEMORY_API_URL="http://100.121.192.94:8400" MATOMO_TOKEN="7d3987d48dcd7fdf9776bd81a4da1778" diff --git a/homelab.conf.bak-20260417-211337 b/homelab.conf.bak-20260417-211337 deleted file mode 100644 index e6563a44..00000000 --- a/homelab.conf.bak-20260417-211337 +++ /dev/null @@ -1,310 +0,0 @@ -# ============================================================ -# homelab.conf — EINZIGE QUELLE DER WAHRHEIT -# ============================================================ -# Wenn sich eine IP, URL, ein Container oder Passwort ändert: -# → NUR DIESE DATEI editieren. -# → sync-state.sh liest hieraus und generiert alles andere. -# → Niemals STATE.md, MOTDs oder Issues manuell pflegen. -# ============================================================ - -# --- DOMAINS --- -DOMAIN_PRIMARY="arakavanews.com" -DOMAIN_OLD="arakava-news-2.orbitalo.net" -DOMAIN_MATOMO="matomo.orbitalo.net" -DOMAIN_SEAFILE="seafile.orbitalo.net" -DOMAIN_GRAFANA="grafana.orbitalo.net" -DOMAIN_PDM="pdm.orbitalo.info" -DOMAIN_RSS="rss-manager.orbitalo.net" -DOMAIN_REDAX="redax.orbitalo.net" - -# ============================================================ -# SERVER — Eindeutige Benennung nach Standort -# ============================================================ -# Kambodscha (KA): 3 Server, LAN 192.168.0.x -# Muldenstein (MU): 3 Server (1 offline), LAN 192.168.178.x -# Ramsin (HE): 1 Server bei Helmut -# Hetzner DC: 1 Server -# ============================================================ - -# --- HETZNER --- -SRV_HETZNER="100.88.230.59" - -# --- KAMBODSCHA (3 Server, Takeo) --- -SRV_KA1="100.122.56.60" -SRV_KA1_LOCAL="192.168.0.197" -SRV_KA1_HOSTNAME="pve-ka-1" - -SRV_KA2="100.120.126.95" -SRV_KA2_LOCAL="192.168.0.198" -SRV_KA2_HOSTNAME="pve-ka-2" - -SRV_KA3="100.103.90.94" -SRV_KA3_LOCAL="192.168.0.199" -SRV_KA3_HOSTNAME="pve-ka-3" - -# --- PHNOM PENH (2 Server, Kondo — pp-cluster) --- -SRV_PP1="100.126.26.46" -SRV_PP1_LOCAL="192.168.0.171" -SRV_PP1_HOSTNAME="pve-pp-1" - -SRV_PP2="100.95.156.25" -SRV_PP2_LOCAL="192.168.0.227" -SRV_PP2_HOSTNAME="pve-pp-2" - -# --- MULDENSTEIN (3 Server, pve-mu-1 aktuell offline) --- -# SRV_MU1="???" -# SRV_MU1_HOSTNAME="pve-mu-1" - -SRV_MU2="100.99.101.37" -SRV_MU2_LOCAL="192.168.178.123" -SRV_MU2_HOSTNAME="pve-mu-2" - -SRV_MU3="100.109.101.12" -SRV_MU3_LOCAL="192.168.178.250" -SRV_MU3_HOSTNAME="pve-mu-3" - -# --- RAMSIN (bei Helmut) --- -SRV_HE="100.87.235.11" -SRV_HE_HOSTNAME="pve-he" - -# --- CURSOR / MONITORING BOT (CT 116 auf pve-mu-2, Muldenstein) --- -SRV_CURSOR="100.88.230.74" -SRV_CURSOR_HOSTNAME="monitoring-bot" - -# --- KI-SERVER (Windows, Muldenstein — Cursor + GPU-Workloads) --- -SRV_KI="100.84.255.83" -SRV_KI_HOSTNAME="KI-Server" -SRV_KI_USER="wutti" -SRV_KI_GPU="NVIDIA RTX 3090 24GB" -SRV_KI_OS="Windows 10 Build 26200" -SRV_KI_SSH="ssh ki-server (Key-Auth via monitoring-bot SOCKS5)" - -# --- BACKUP (PBS) --- -SRV_PBS_MU="100.99.139.22" -SRV_PBS_KA="lokal" - -# --- PASSWÖRTER --- -PW_HETZNER="Astral-Proxmox!2026" -PW_DEFAULT="astral66" -PW_WP_ADMIN="eJIyhW0p5PFacjvvKGufKeXS" -PW_5V8_USER="Holgerhh" -PW_5V8_PASS="ddlhh" -PW_EDELMETALL_DASHBOARD="" -PW_PDM_USER="root" -PW_PDM_PASS="astral66" - -# ============================================================ -# CONTAINER — Format: CT__="name|tailscale_ip|dienste" -# Servercodes: HZ=Hetzner, KA1/2/3=Kambodscha, MU2/3=Muldenstein, HE=Ramsin -# ============================================================ - -# --- pve-hetzner (Hauptinfrastruktur) --- -CT_101_HZ="wordpress-v2|100.91.212.19|WordPress + MySQL (Docker) — arakavanews.com" -CT_103_HZ="seafile|100.75.247.60|Seafile (Docker)" -CT_109_HZ="rss-manager|100.113.244.101|RSS Manager + Matomo — WP intern via http://10.10.10.101" -CT_110_HZ="portainer|100.109.206.43|Portainer Docker UI + Loki Stack" -CT_111_HZ="forgejo|100.89.246.60|Forgejo Git Server" -CT_112_HZ="fuenfvoracht|100.73.171.62|FuenfVorAcht Telegram Bot" -CT_113_HZ="redax-wp|100.69.243.16|Redakteur WordPress KI-Autor + DeutschlandBlog" -CT_115_HZ="flugscanner-hub|100.92.161.97|Flugpreisscanner Hub + Scheduler" -CT_116_HZ="homelab-ai-bot (1 GB RAM)|100.123.47.7|Hausmeister Bot (Qwen3-VL 30B via Ollama/KI-Server, Text+Vision) + Save.TV Web-UI + web_search via SearXNG" -CT_121_HZ="deep-research|100.74.196.29|Open Deep Research + SearXNG — LangGraph API auf Port 2024" -CT_117_HZ="memory-service|100.121.192.94|Memory Service API (FastAPI + SQLite)" -CT_144_HZ="muldenstein-backup|—|Backup-Archiv (Read-Only)" -CT_999_HZ="cluster-docu|100.79.8.49|Dokumentation" - -# --- pve-ka-1 (Kambodscha, Hauptserver) --- -CT_110_KA1="uptime-kuma|—|Uptime Monitoring" -CT_115_KA1="flugscanner-asia|100.112.190.22|Scraping-Node Asia" -CT_118_KA1="Django-Klon-Neu|—|Django App (Taxi)" -CT_134_KA1="gold-silber-v3|100.72.230.87|Edelmetall Dashboard + Telegram Bot" -CT_200_KA1="doc-converter|—|Dokument-Konverter" -CT_888_KA1="MCP-Proxmox|—|MCP Server" -CT_999_KA1="cluster-docu|—|Dokumentation" -VM_100_KA1="debian|—|Debian VM" - -# --- pve-pp-1 (Phnom Penh, Kondo — Arbeitsmaschine) --- -CT_100_PP1="yt-desktop|100.112.224.39|XFCE Desktop + xrdp + Chromium/Firefox + Seafile-Sync (Videos) + NFS-Mount Torrents" -CT_103_PP1="torrent|—|qBittorrent Web-UI :8080 (192.168.0.129) + NFS-Export → CT 100" - -# --- pve-pp-2 (Phnom Penh, Kondo — Reserve/Standby) --- -CT_101_PP2="yt-desktop-standby|—|Standby-Kopie CT 100 (gestoppt)" -CT_102_PP2="torrent|—|qBittorrent Web-UI :8080 (192.168.0.193)" - -# --- pve-ka-2 (Kambodscha, Shop-Server) --- -CT_504_KA2="Shop-Template|—|Shop Template (stopped)" -CT_8000_KA2="Kunde0-Shop|—|Kunde 0 Shop (stopped)" -CT_8010_KA2="Kunde1-Shop|—|Kunde 1 Shop (stopped)" - -# --- pve-ka-3 (Kambodscha, Webcam + Dienste) --- -CT_101_KA3="freshrss|—|FreshRSS Reader" -CT_103_KA3="Intercity-Taxi|—|Intercity Taxi App" -CT_104_KA3="bt-search|—|BT Search" -CT_141_KA3="llm-router-dev|—|LLM Router Entwicklung" -CT_600_KA3="webcam|100.80.76.118|Restreamer + Dahua 4K Cam → cam.arakavanews.com" -VM_500_KA3="frigate-vm|100.104.64.99|Frigate NVR + Coral TPU — 3 Kameras, GUI :5000" - -# --- pve-mu-2 (Muldenstein, Shop- & Entwicklungsserver) --- -CT_111_MU2="uptimekuma|—|Uptime Monitoring" -CT_112_MU2="myspeed|—|Internet Speedtest" -CT_113_MU2="pve-scripts-local|—|PVE Helper Scripts" -CT_114_MU2="djangoadmin|—|Django Admin" -CT_115_MU2="Takeo-PC-Shop-Engl|—|PC Shop (englisch)" -CT_116_MU2="monitoring-bot|100.88.230.74|Cursor IDE + Tailscale-Gateway (userspace) + SSH-Hub — CT 116 auf pve-mu-2 (Debian 13, 3.4GB RAM)" -CT_117_MU2="Intercity-Taxi|—|Intercity Taxi" -CT_123_MU2="Kofi-Shop-PP|—|Kofi Shop Phnom Penh" -CT_128_MU2="rustdeskserver|—|RustDesk Remote Desktop" -CT_130_MU2="PC-Shop-Takeo|—|PC Shop Takeo" -CT_131_MU2="PC-Shopp-PP|—|PC Shop Phnom Penh" -CT_136_MU2="Seleniumbase|—|Selenium Scraping" -CT_140_MU2="Alfredo-Pizza|—|Pizza Alfredo" -CT_150_MU2="Pizza-Express-Wolfen|—|Pizza Express Wolfen" -CT_160_MU2="Red-Pizza|—|Red Pizza" -CT_180_MU2="Mellensa-Pizza|—|Mellensa Pizza" -CT_190_MU2="Ali-Baba|—|Ali Baba" -CT_200_MU2="Pizza-Di-Angelo|—|Pizza Di Angelo" -CT_500_MU2="Test-Shop|—|Test Shop" -CT_501_MU2="Test-Shop-Prod|—|Test Shop Produktion" -CT_502_MU2="Test-Shop-2|—|Test Shop 2" - -# --- pve-mu-3 (Muldenstein, Infrastruktur + Mirrors) --- -CT_139_MU3="Syncthing-Muldenstein|—|Syncthing" -CT_141_MU3="syncthing|—|Syncthing" -CT_142_MU3="WG-easy|—|WireGuard VPN" -CT_143_MU3="Raspi-Broker|—|ioBroker MQTT Broker" -CT_145_MU3="flugscanner-mu|100.75.182.15|Flugpreisscanner Node DE" -CT_504_MU3="projektscan-template|—|Projektscan Template" -CT_600_MU3="wp-mirror|100.92.205.101|WordPress Mirror (Redundanz CT 101)" -CT_601_MU3="rss-mirror|—|RSS Manager Mirror (Redundanz CT 109)" -CT_700_MU3="ragflow|192.168.178.154|RAGFlow PDF-RAG (Docker, Ollama/KI-Server, Synology SMB) — ~13k PDFs" -VM_144_MU3="BT-Bridge|—|BT Bridge VM" - -# --- pve-he (Ramsin, bei Helmut) --- -# Container noch nicht inventarisiert - -# --- TELEGRAM BOTS --- -TG_CHAT_ID="674951792" -TG_MUTTER_TOKEN="8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE" -TG_FUENFVORACHT_TOKEN="8799990587:AAEoQuohGdoJ2WudoOHs_j5Ns3iwft6OlFc" -TG_EDELMETALL_TOKEN="8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4" -TG_HAUSMEISTER_TOKEN="8390233104:AAHdgF6r7qZsQEZHIBHPV1ky3v-6-YULvj8" - -# --- PROXMOX API TOKENS --- -PVE_TOKEN_HETZNER_NAME="mcp-homelab" -PVE_TOKEN_HETZNER_VALUE="e986d3d5-36c0-425c-b1bb-20ed650a8065" - -# --- API KEYS --- -FORGEJO_TOKEN="b874766bdf357bd4c32fa4369d0c588fc6193336" -FORGEJO_SYNC_TOKEN="b874766bdf357bd4c32fa4369d0c588fc6193336" -GITHUB_PAT="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" -OPENROUTER_KEY="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" -OPENAI_API_KEY="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" -MEMORY_API_TOKEN="Ai8eeQibV6Z1RWc7oNPim4PXB4vILU1nRW2-XgRcX2M" -MEMORY_API_URL="http://100.121.192.94:8400" -MATOMO_TOKEN="7d3987d48dcd7fdf9776bd81a4da1778" -MATOMO_URL="http://100.113.244.101" -MATOMO_SITE_ID="1" - -# --- HOMELAB MCP-SERVER (auf pve-hetzner Host) --- -MCP_PATH="/root/homelab-mcp" -MCP_VENV="/root/homelab-mcp/.venv" -MCP_TOOLS="homelab_overview,homelab_all_containers,homelab_container_status,homelab_query_logs,homelab_get_errors,homelab_check_silence,homelab_host_health,homelab_metrics,homelab_get_config,homelab_loki_labels,homelab_prometheus_targets" - -# --- SAVE.TV (Online-Videorecorder) --- -SAVETV_USER="739281" -SAVETV_PASS="Astral1966" -SAVETV_URL="https://www.save.tv" -# Download-Pipeline: AKTIV (Save.TV → Hetzner CT116 → Jellyfin-Server → NAS Muldenstein) -# savetv_sync.py läuft stündlich auf Jellyfin-Server (100.77.105.3), 24h±30min Delay, min. 700MB -# CT 116 /etc/hosts: www.save.tv → 172.66.146.119 (DNS-GIL-Fix) -# CT 116 RAM: 1 GB (war 512 MB, hat alles einfrieren lassen) -# Ziel: Samba-Share auf RAID in Muldenstein → Jellyfin-Mediathek -# Architektur: Save.TV → pve-hetzner (temp) → Samba/CIFS → Jellyfin-Ordner -# TODO: Share-IP, Share-Name, Credentials, Jellyfin-Pfad ermitteln -# TODO: Download-Endpoint reverse-engineeren (vermutlich SendungsDetails.cfm) -# TODO: cifs-mount oder smbclient fuer Transfer -# Status: Login+EPG+AutoRecord FERTIG | Download+Sync OFFEN - -# --- E-MAIL (All-Inkl IMAP-Spiegel von GMX) --- -MAIL_IMAP_SERVER="w0206aa8.kasserver.com" -MAIL_IMAP_PORT="993" -MAIL_USER="info@orbitalo.info" -MAIL_PASS="Astral-66" - -# --- LOKI --- -LOKI_URL="http://100.109.206.43:3100" -LOKI_CT="110" - -# --- PROMETHEUS --- -PROMETHEUS_URL="http://100.88.230.59:9090" -PROMETHEUS_STATUS="aktiv" - -# --- ROUTING (Cloudflare Tunnels) --- -# Format: TUNNEL__="domain|ziel|status" -TUNNEL_101_HZ="arakavanews.com|:80|aktiv" -TUNNEL_101_HZ_OLD="arakava-news-2.orbitalo.net|301→arakavanews.com|aktiv" -TUNNEL_109_HZ="matomo.orbitalo.net|:80|aktiv" -TUNNEL_600_KA3="cam.arakavanews.com|:8080|aktiv" -TUNNEL_600_MU3="arakavanews.com|:80|standby" -TUNNEL_601_MU3="rss-manager|:8080|standby" - -# ============================================================ -# GEPLANTE HARDWARE (noch nicht online) -# ============================================================ - -# --- KI-Tower (Muldenstein, geplant) --- -# Logischer Name: ki-tower -# CPU: AMD Ryzen 7 7700 (8C/16T) -# RAM: 64 GB DDR5 -# GPU: NVIDIA RTX 3090 (24 GB VRAM) -# Storage: 1 TB NVMe -# Rolle: Chef — Orchestrator, Szenenplan (Qwen 14B), Hero-Bilder (FLUX.1-dev), -# Assembly (FFmpeg + NVENC), production.db (SQLite) -# Skripte: GPT-5.4 via OpenAI API (Cloud, ~0.10-0.50 EUR/Skript) -# OS: geplant Debian 12 + Docker + CUDA -# Dienste: vLLM (:8401), ComfyUI (:8402), Orchestrator (Python) -# Projekt: ki-video/PLAN.md -# Status: geplant - -# --- NVIDIA Worker-Rig (Muldenstein, geplant) --- PRIMAERER WORKER -# Logischer Name: gpu-worker -# GPUs: 4x NVIDIA RTX 3080 (je 10 GB GDDR6X, PCIe 4.0 x16) -# CUDA: voll unterstuetzt, identischer Stack wie ki-tower -# Rolle: Produktiver Worker-Pool fuer KI-Video Pipeline -# GPU #0: XTTS v2 (TTS, ~4 GB) → danach SDXL Bilder (~7 GB) :8501 -# GPU #1: SDXL (Standard-Szenen, ~7 GB, durchgehend) :8502 -# GPU #2: SadTalker (Avatar lip-sync, ~6 GB, wartet auf TTS) :8503 -# GPU #3: Real-ESRGAN (Upscaling) + Whisper (Untertitel) :8504 -# OS: geplant Debian 12 + Docker + CUDA -# Architektur: 1 Container pro GPU, feste Zuordnung, HTTP-API pro Worker -# Status: geplant -# -# --- AMD Mining-Rig (Muldenstein, Reserve) --- NEBENROLLE -# Logischer Name: gpu-reserve -# GPUs: 8x AMD Radeon RX 6600 XT Dual (je 8 GB GDDR6, PCIe 4.0 x8) -# Chip: Navi 23 (gfx1032), RDNA 2 -# ROCm: inoffiziell (HSA_OVERRIDE_GFX_VERSION=10.3.0 noetig) -# Funktion: Reserve/Nebenrolle — Whisper (whisper.cpp), CPU-Batch-Jobs -# Prioritaet: Niedrig. Nur einsetzen wenn 3080-Rig ausgelastet. -# Alternative: Verkaufen und Erloese in Storage/RAM investieren. -# OS: geplant Debian 12 + Docker + ROCm (falls benoetigt) -# Status: zurueckgestellt, Entscheidung nach 3080-Rig-Aufbau - -# ============================================================ -# NAMENS-MAPPING (Alt → Neu) — Referenz für Migration -# ============================================================ -# pve-hetzner → pve-hetzner (unverändert) -# pve1 → pve-ka-1 (Kambodscha, Hauptserver) -# pve-Shops → pve-ka-2 (Kambodscha, Shop-Server) -# pve3 (KH) → pve-ka-3 (Kambodscha, Webcam + Dienste) -# pve2 → pve-mu-2 (Muldenstein, Shops & Entwicklung) -# pve3 (MU) → pve-mu-3 (Muldenstein, Infra + Mirrors) -# pve-mu-1 → offline (Muldenstein, noch nicht inventarisiert) -# helmut-pve → pve-he (Ramsin, bei Helmut) -# PBS → pbs-mu (PBS Muldenstein) -# ============================================================ - -# OpenMemory (CT 122) - optional, Default: http://10.10.10.122:8765 -# OPENMEMORY_API_URL="http://10.10.10.122:8765" -# OPENMEMORY_USER_ID="orbitalo" -