diff --git a/backend/services/agent.py b/backend/services/agent.py index 41e4798..bb11fdb 100644 --- a/backend/services/agent.py +++ b/backend/services/agent.py @@ -107,12 +107,14 @@ def agent_status() -> dict: } -def set_agent_brain(model_id: str) -> dict: +def set_agent_brain(model_id: str, hermes_umstellen: bool = True) -> dict: """Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst: 1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot), 2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben), 3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart. - So bleibt das neue Hirn warm und der Agent nutzt es sofort.""" + So bleibt das neue Hirn warm und der Agent nutzt es sofort. + hermes_umstellen=False: Schritt 3 macht der Aufrufer selbst — etwa das Radar, das erst nach dem + einen gesammelten Schreibvorgang der llama-swap-Config Hermes neu startet.""" from services import llamaswap models = {m["name"]: m for m in llamaswap.list_models()} if model_id not in models: @@ -122,26 +124,30 @@ def set_agent_brain(model_id: str) -> dict: # Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen. try: from services.llamaswap import set_ttl - brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] - if model_id not in brains: - llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True) - set_ttl(model_id, 0) + with llamaswap.sammeln(): + brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] + if model_id not in brains: + llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True) + set_ttl(model_id, 0) except PermissionError as exc: return {"ok": False, "reason": str(exc)} return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"} try: - llamaswap.set_role(model_id, "hermes") # 1) Alias - brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] - new_members = [x for x in brains if x not in (old, model_id)] + [model_id] - llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm - # 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen. from services.llamaswap import DEFAULT_TTL, set_ttl - set_ttl(model_id, 0) - if old: - set_ttl(old, DEFAULT_TTL) + # Alias, Gruppe und ttl als EIN Schreibvorgang (24.09.2026) — jeder einzelne entlud alle Modelle. + with llamaswap.sammeln(): + llamaswap.set_role(model_id, "hermes") # 1) Alias + brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] + new_members = [x for x in brains if x not in (old, model_id)] + [model_id] + llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm + # 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen. + set_ttl(model_id, 0) + if old: + set_ttl(old, DEFAULT_TTL) except PermissionError as exc: return {"ok": False, "reason": str(exc)} - update_brain_model("hermes") # 3) Config + Restart + if hermes_umstellen: + update_brain_model("hermes") # 3) Config + Restart # Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT? warning = None try: diff --git a/backend/services/llamaswap.py b/backend/services/llamaswap.py index fe844a7..b48809a 100644 --- a/backend/services/llamaswap.py +++ b/backend/services/llamaswap.py @@ -93,6 +93,29 @@ def config_sperre(): datei.close() +# Sammel-Schreiben (24.09.2026): Jeder Schreibvorgang lässt llama-swap neu laden und ALLE Modelle +# entladen. Ein Modelltausch bestand aus bis zu sieben Schreibvorgängen (Eintragen, Befehl, Zwilling, +# Alias, Gruppe, ttl …). Innerhalb von sammeln() lesen alle Änderungen dieselbe Config aus dem +# Speicher, und geschrieben wird einmal am Ende — oder gar nicht, wenn etwas scheitert. +_SAMMEL = threading.local() + + +@contextmanager +def sammeln(): + with config_sperre(): + if getattr(_SAMMEL, "aktiv", False): # verschachtelt: der äußere schreibt + yield + return + _SAMMEL.aktiv, _SAMMEL.cfg = True, None + try: + yield + cfg = _SAMMEL.cfg + finally: + _SAMMEL.aktiv, _SAMMEL.cfg = False, None + if cfg is not None: + _schreiben(cfg) + + def _mit_sperre(fn): @functools.wraps(fn) def huelle(*args, **kwargs): @@ -103,6 +126,14 @@ def _mit_sperre(fn): # --- Lesen ------------------------------------------------------------------- def read_config() -> dict: + if getattr(_SAMMEL, "aktiv", False): + if _SAMMEL.cfg is None: + _SAMMEL.cfg = _lesen() + return _SAMMEL.cfg + return _lesen() + + +def _lesen() -> dict: if not CONFIG_PATH.exists(): return {"models": {}} from ruamel.yaml import YAML @@ -260,7 +291,15 @@ def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str: def write_config(cfg: dict) -> None: """Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie - eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung.""" + eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung. Innerhalb von sammeln() + wird nur vorgemerkt; geschrieben wird am Ende einmal.""" + if getattr(_SAMMEL, "aktiv", False): + _SAMMEL.cfg = cfg + return + _schreiben(cfg) + + +def _schreiben(cfg: dict) -> None: try: CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp") diff --git a/backend/services/radar.py b/backend/services/radar.py index 8118408..3e23d11 100644 --- a/backend/services/radar.py +++ b/backend/services/radar.py @@ -1383,8 +1383,9 @@ def _tausche_ein(k: dict) -> dict: else: ziel = quelle # schon verschoben (früherer, abgebrochener Versuch) pfade = _lokale_pfade(k, ziel) - # Eintragen, Befehl ersetzen und Zwilling anlegen unter EINER Config-Sperre (24.09.2026). - with llamaswap.config_sperre(): + # Eintragen, Befehl, Zwilling, Rollen, Gruppe und ttl als EIN Schreibvorgang (24.09.2026): Vorher + # schrieb ein Tausch die Config bis zu siebenmal, und jedes Mal entlud llama-swap alle Modelle. + with llamaswap.sammeln(): modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], ctx=int(k.get("ctx") or CTX_ROLLE[rolle]), mmproj_path=None, jinja=True, set_alias=False) @@ -1396,27 +1397,32 @@ def _tausche_ein(k: dict) -> dict: if pfade.get("mmproj"): _zwilling_eintragen(cfg, rolle, modell_id, pfade, k) llamaswap.write_config(cfg) + antwort: dict = {} + if rolle == "hirn": + from services.agent import set_agent_brain + # hermes-Alias, brains-Gruppe, ttl 0 — Hermes selbst erst nach dem Schreiben (unten). + antwort = set_agent_brain(modell_id, hermes_umstellen=False) + if not antwort.get("ok"): + raise RuntimeError(antwort.get("reason") or "Hirn-Wechsel fehlgeschlagen") + # Das Hirn ist heute hermes UND fast (Chat-Spur, warmup.sh, Oberfläche). Bliebe fast am alten + # Modell, lüde warmup.sh es nachts neben das neue Hirn. + if alt and "fast" in {str(a).lower() for a in alt.get("aliases") or []}: + llamaswap.add_role(modell_id, "fast") + else: + if not llamaswap.set_role(modell_id, "coder"): + raise RuntimeError(f"Die Rolle coder ließ sich nicht auf {modell_id} setzen.") + # heavy ist heute derselbe Coder (OpenCode plant damit). Bliebe es am alten Modell, lägen zwei + # Coder bereit und der alte ließe sich nie löschen. + if alt and "heavy" in {str(a).lower() for a in alt.get("aliases") or []}: + llamaswap.add_role(modell_id, "heavy") + if alt and alt.get("ttl") is not None: + llamaswap.set_ttl(modell_id, int(alt["ttl"])) hinweis = None if rolle == "hirn": - from services.agent import set_agent_brain - antwort = set_agent_brain(modell_id) # hermes-Alias, brains-Gruppe, ttl 0, Hermes-Config - if not antwort.get("ok"): - raise RuntimeError(antwort.get("reason") or "Hirn-Wechsel fehlgeschlagen") - # Das Hirn ist heute hermes UND fast (Chat-Spur, warmup.sh, Oberfläche). Bliebe fast am alten - # Modell, lüde warmup.sh es nachts neben das neue Hirn. - if alt and "fast" in {str(a).lower() for a in alt.get("aliases") or []}: - llamaswap.add_role(modell_id, "fast") + from services.agent import update_brain_model + update_brain_model("hermes") # Hermes-Config + Neustart, jetzt mit fertiger Config hinweis = " ".join(h for h in (antwort.get("warning"), _warmset_umstellen((alt or {}).get("name"), modell_id)) if h) or None - else: - if not llamaswap.set_role(modell_id, "coder"): - raise RuntimeError(f"Die Rolle coder ließ sich nicht auf {modell_id} setzen.") - # heavy ist heute derselbe Coder (OpenCode plant damit). Bliebe es am alten Modell, lägen zwei Coder - # bereit und der alte ließe sich nie löschen. - if alt and "heavy" in {str(a).lower() for a in alt.get("aliases") or []}: - llamaswap.add_role(modell_id, "heavy") - if alt and alt.get("ttl") is not None: - llamaswap.set_ttl(modell_id, int(alt["ttl"])) return {"modell_id": modell_id, "pfad": str(ziel), "alt": (alt or {}).get("name"), "hinweis": hinweis} diff --git a/backend/tests/test_llamaswap_sammeln.py b/backend/tests/test_llamaswap_sammeln.py new file mode 100644 index 0000000..cb33b91 --- /dev/null +++ b/backend/tests/test_llamaswap_sammeln.py @@ -0,0 +1,119 @@ +"""Sammel-Schreiben der llama-swap-Config (24.09.2026): Ein Modelltausch schrieb die Config bis zu +siebenmal, und jedes Mal entlud llama-swap alle Modelle. In llamaswap.sammeln() gibt es genau einen +Schreibvorgang — oder keinen, wenn etwas scheitert.""" + +import threading + +import pytest +from services import llamaswap + +CONFIG = """\ +models: + alt: + cmd: llama-server -m /srv/models/alt.gguf + aliases: + - hermes + - fast + ttl: 0 + neu: + cmd: llama-server -m /srv/models/neu.gguf + ttl: 300 +groups: + brains: + swap: false + persistent: true + members: + - alt +""" + + +@pytest.fixture +def config(tmp_path, monkeypatch): + datei = tmp_path / "config.yaml" + datei.write_text(CONFIG, encoding="utf-8") + monkeypatch.setattr(llamaswap, "CONFIG_PATH", datei) + schreibvorgaenge: list[int] = [] + echt = llamaswap._schreiben + + def zaehlen(cfg): + schreibvorgaenge.append(1) + echt(cfg) + + monkeypatch.setattr(llamaswap, "_schreiben", zaehlen) + return datei, schreibvorgaenge + + +def test_ohne_sammeln_schreibt_jede_aenderung(config): + _, schreibvorgaenge = config + llamaswap.set_role("neu", "coder") + llamaswap.set_ttl("neu", 600) + assert len(schreibvorgaenge) == 2 + + +def test_sammeln_schreibt_einmal_mit_allen_aenderungen(config): + datei, schreibvorgaenge = config + with llamaswap.sammeln(): + llamaswap.set_role("neu", "hermes") + llamaswap.add_role("neu", "fast") + llamaswap.set_group("brains", ["neu"], swap=False, persist=True) + llamaswap.set_ttl("neu", 0) + llamaswap.set_ttl("alt", 300) + # Innerhalb des Sammelns sieht jede Abfrage den gesammelten Stand … + assert "hermes" in next(m for m in llamaswap.list_models() if m["name"] == "neu")["aliases"] + # … die Datei aber noch den alten. + assert "- hermes" in datei.read_text(encoding="utf-8").split("neu:")[0] + assert schreibvorgaenge == [] + assert len(schreibvorgaenge) == 1 + modelle = {m["name"]: m for m in llamaswap.list_models()} + assert {"hermes", "fast"} <= set(modelle["neu"]["aliases"]) and modelle["neu"]["ttl"] == 0 + assert "hermes" not in modelle["alt"]["aliases"] and modelle["alt"]["ttl"] == 300 + assert llamaswap.list_groups()["brains"]["members"] == ["neu"] + + +def test_sammeln_schreibt_nichts_wenn_etwas_scheitert(config): + datei, schreibvorgaenge = config + vorher = datei.read_text(encoding="utf-8") + with pytest.raises(RuntimeError), llamaswap.sammeln(): + llamaswap.set_role("neu", "hermes") + raise RuntimeError("Hirn-Wechsel fehlgeschlagen") + assert schreibvorgaenge == [] and datei.read_text(encoding="utf-8") == vorher + + +def test_verschachtelt_schreibt_der_aeussere(config): + _, schreibvorgaenge = config + with llamaswap.sammeln(): + llamaswap.set_ttl("neu", 1) + with llamaswap.sammeln(): + llamaswap.set_ttl("neu", 2) + assert schreibvorgaenge == [] + assert len(schreibvorgaenge) == 1 + assert next(m for m in llamaswap.list_models() if m["name"] == "neu")["ttl"] == 2 + + +def test_andere_threads_sehen_den_gesammelten_stand_nicht(config): + gesehen: list[list[str]] = [] + with llamaswap.sammeln(): + llamaswap.set_role("neu", "coder") + t = threading.Thread(target=lambda: gesehen.append( + next(m for m in llamaswap._lesen()["models"] if m == "neu") and + list(llamaswap._lesen()["models"]["neu"].get("aliases") or []))) + t.start() + t.join() + assert gesehen == [[]] + + +def test_hirn_umstellung_schreibt_einmal(config, monkeypatch): + from services import agent + + _, schreibvorgaenge = config + monkeypatch.setattr(agent, "hermes_brain_info", dict) + umgestellt: list[str] = [] + monkeypatch.setattr(agent, "update_brain_model", lambda alias: umgestellt.append(alias) or True) + antwort = agent.set_agent_brain("neu", hermes_umstellen=False) + assert antwort["ok"] and antwort["old"] == "alt" + assert len(schreibvorgaenge) == 1 and umgestellt == [] + modelle = {m["name"]: m for m in llamaswap.list_models()} + assert "hermes" in modelle["neu"]["aliases"] and modelle["neu"]["ttl"] == 0 + assert llamaswap.list_groups()["brains"]["members"] == ["neu"] + agent.set_agent_brain("neu") # schon das Hirn: härtet nur nach, Hermes wird nicht angefasst + assert umgestellt == [] diff --git a/backend/tests/test_radar.py b/backend/tests/test_radar.py index 7038d78..24d9c0f 100644 --- a/backend/tests/test_radar.py +++ b/backend/tests/test_radar.py @@ -374,7 +374,10 @@ def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tupl monkeypatch.setattr(radar.llamaswap, "add_role", lambda mid, r: aufrufe.append(("dazu", mid, r)) or True) monkeypatch.setattr(radar, "_steward_neu_starten", lambda: aufrufe.append(("steward",)) or None) monkeypatch.setattr(radar.llamaswap, "set_ttl", lambda mid, ttl: aufrufe.append(("ttl", mid, ttl)) or True) - monkeypatch.setattr(agent, "set_agent_brain", lambda mid: aufrufe.append(("hirn", mid)) or {"ok": True}) + monkeypatch.setattr(agent, "set_agent_brain", + lambda mid, hermes_umstellen=True: aufrufe.append(("hirn", mid, hermes_umstellen)) or {"ok": True}) + # Nie die echte Hermes-Config: Auf der Box läuft dieser Test im Deploy-Prüftor. + monkeypatch.setattr(agent, "update_brain_model", lambda alias: aufrufe.append(("hermes-config", alias)) or True) monkeypatch.setattr(radar, "STEWARD_WARMSET", zustand / "warmset.conf") return aufrufe, cfg @@ -386,8 +389,10 @@ def test_uebernehmen_hirn_nutzt_den_hirn_wechsel_und_nimmt_fast_mit(zustand, mon encoding="utf-8") antwort = radar.uebernehmen("o") assert antwort["ok"] and antwort["modell_id"] == "o" - assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"] - assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast") + # Hermes wird erst umgestellt, wenn die llama-swap-Config einmal geschrieben ist (24.09.2026). + assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "hermes-config", "steward"] + assert aufrufe[0][2:] == ("hermes", False) and aufrufe[2] == ("hirn", "o", False) + assert aufrufe[3] == ("dazu", "o", "fast") and aufrufe[4] == ("hermes-config", "hermes") befehl = str(cfg["models"]["o"]["cmd"]) assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" not in befehl # Wie im Betrieb seit 24.09.: Bilder sieht der neue Bild-Zwilling (Projektor, ohne Draft), der alte fliegt raus. diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md index 16d9f84..faf5349 100644 --- a/docs/ARCHITEKTUR.md +++ b/docs/ARCHITEKTUR.md @@ -108,6 +108,27 @@ Fremde Dienste, die der Box-Wart nur steuert oder überwacht: `llama-swap` (Syst - **Ohne systemd** (PC, Tests, `MC_JOBS_ART=prozess`) läuft der Auftrag als Kindprozess und überlebt keinen Neustart. Ein Probelauf daneben (`MC_PROBELAUF=1`) nimmt keine Aufträge auf. +## Ziele, Bausteine und Update-Verlauf (seit 24.09., Phase 2d) + +- **Gemeinsames Modell** `backend/kern/ziele.py`: Ein Ziel ist ein Gerät (KI-Box, später Proxmox-Host, Container, + Arcane-VM) mit Bausteinen. Jeder Baustein hat einen Stand (`neu`, `aktuell`, `unbekannt` mit Grund, + `festgehalten`, `wird-geprueft`), Versionen und den Knopf, der das Update anstößt (Methode, Pfad, Rückfrage). +- **Box-Adapter** `services/box_updates.py` (`ki_box_ziel()`): Betriebssystem, Motor, llama-swap und Hermes aus dem + Zwischenspeicher der Update-Prüfung (10 Minuten, nach jedem Update sofort neu), Pins als `festgehalten`, + gescheiterte Prüfungen als `unbekannt`. `GET /api/ziele` liefert die KI-Box; der Homelab-Teil liefert seine + Ziele ab Phase 3 unter `/api/homelab/ziele`, die Oberfläche legt beide zu einer Liste zusammen. +- **Strukturierter Update-Verlauf** `/mc2-update-verlauf.jsonl`: je Baustein eine JSON-Zeile + (`lauf`, `anlass`, `ts`, `baustein`, `ergebnis`, `text`). Es schreiben `autoupdate.sh` (Helfer `ergebnis`/`verlauf`; + die Telegram-Texte bleiben Zeichen für Zeichen gleich) und die Update-Knöpfe (Abschluss-Haken + `wartung:verlauf` der Aufträge, bei „Alle aktualisieren" mit dem Teil, an dem die Kette scheiterte). + `services/update_verlauf.py` liest ab dem ersten strukturierten Eintrag nur noch ihn, ältere Läufe weiter aus + `~/mc2-notify.log`. Den Hermes-Auftrag des Sonntags-Laufs startet `autoupdate.sh` mit `?verlauf=0`, damit er + nicht doppelt erscheint. +- **Sammel-Schreiben der llama-swap-Config** `llamaswap.sammeln()`: Alle Änderungen darin lesen dieselbe Config aus + dem Speicher; geschrieben wird einmal am Ende, bei einem Fehler gar nicht. Ein Radar-Tausch (Eintragen, Befehl, + Zwilling, Alias, Gruppe, ttl) und die Hirn-Umstellung sind damit je ein Schreibvorgang statt bis zu sieben; + jeder Schreibvorgang lässt llama-swap alle Modelle entladen. Hermes wird erst danach umgestellt. + ## Modelle und Modell-Rollen - **Rollen-Aliase statt Namen:** Clients fragen `hermes`/`fast` (Hirn), `coder`/`heavy` (Coder), `vision` und @@ -142,7 +163,8 @@ Fremde Dienste, die der Box-Wart nur steuert oder überwacht: `llama-swap` (Syst | `/srv/models/mc2-geheimnisse.json` | MC2 (Einstellungen) | Hugging-Face-Zugang, Rechte 0600 | | `/srv/models/mc2-discover.json` | MC2, Radar | Cache der Hugging-Face-Entdeckung (12 h) | | `/srv/models/mc2-deploy.log` | `deploy.sh` | eine Zeile je Deploy, auch gescheiterte | -| `~/mc2-notify.log` | `notify.sh` (anhängen), `morgenmeldung.sh` (über 3 MB auf 2 MB kürzen) | Quelle des Update-Verlaufs; der Wortlaut „QUEUED für Morgen-Digest" muss bleiben | +| `~/mc2-notify.log` | `notify.sh` (anhängen), `morgenmeldung.sh` (über 3 MB auf 2 MB kürzen) | Quelle des Update-Verlaufs für Läufe vor dem 24.09.; der Wortlaut „QUEUED für Morgen-Digest" muss bleiben | +| `/srv/models/mc2-update-verlauf.jsonl` | `autoupdate.sh`, MC2 (Update-Knöpfe) | nur anhängen, eine JSON-Zeile je Baustein | | `~/.hermes/night-queue.txt` | `notify.sh` (anhängen), `morgenmeldung.sh` (übernehmen, senden) | nicht gesendeter Stapel bleibt als `.senden` liegen | ## Meldeweg diff --git a/docs/wissen/OFFENE-FAEDEN.md b/docs/wissen/OFFENE-FAEDEN.md index 3f3bb85..8cdc2ad 100644 --- a/docs/wissen/OFFENE-FAEDEN.md +++ b/docs/wissen/OFFENE-FAEDEN.md @@ -25,13 +25,10 @@ Hintergrund zur Homelab-Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt ## Offene Einzelpunkte (Box-Wart) -1. **Ein Modelltausch schreibt die llama-swap-Config mehrfach.** „Übernehmen" im Radar trägt das Modell unter einer - Sperre ein; Hirn-Umstellung, Aliase `fast`/`heavy` und ttl folgen als eigene Schreibvorgänge. Jeder lädt - llama-swap neu und entlädt alle Modelle. Ziel: ein Schreibvorgang je Tausch. -2. **Session-Token des Hermes-Dashboards (nur mit User-Ja).** Das Drop-in +1. **Session-Token des Hermes-Dashboards (nur mit User-Ja).** Das Drop-in `hermes-builtin-ui.service.d/session-token.conf` aus der früheren Desktop-Anbindung ist weiter gesetzt; `backup.sh` und `restore.sh` sichern es samt Kopie `desktop-gateway-token`. Entfernen ist Security-Config. -3. **Rote Wächter-Hinweise warten nachts bis 07:00.** Der Wächter meldet mit Betreff „[Box-Problem]" ohne `-d`; zwischen +2. **Rote Wächter-Hinweise warten nachts bis 07:00.** Der Wächter meldet mit Betreff „[Box-Problem]" ohne `-d`; zwischen 00:00 und 06:59 landet das in der Morgenmeldung. Klären, ob Rot sofort raus soll. 6. **Code verweist auf verschobene Doku:** `deploy/mc2-backup.service` (`Documentation=` auf `docs/BACKUP.md`), `backend/services/backup.py` (`docs/BACKUP.md`), `backend/services/maintenance.py` (`docs/BEDIENUNG.md`,