From af67ee89e37bf362e6b5c202a519b2cb4a262c0c Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:33:46 +0200 Subject: [PATCH 01/26] feat: signierte 1h-Testzugangslinks fuer den Leitstand ("agent-one test") Gegenstueck zum "agent-one test"-Kommando in Alice (dillenberg.net, dilles-agent.php). Kein Live-Aufruf zwischen den Systemen noetig - beide teilen sich TESTZUGANG_SECRET, das Token ist ein HMAC-signierter Ablauf-Zeitstempel ("ablauf.signatur"), stateless geprueft. pipe/testzugang.py: gueltig(token) prueft Signatur (hmac.compare_digest) und Ablauf. _angemeldet() in server.py akzeptiert das Token zusaetzlich zum Passwort - per ?zugang=... in der URL oder per zugang-Cookie fuer Folgeanfragen (fetch()-Aufrufe der Seite tragen die URL nicht mit). Ein frisches URL-Token setzt das Cookie (HttpOnly, Secure, SameSite=Lax) fuer den Rest der Stunde. End-to-End getestet: PHP-generiertes Token wird von Python akzeptiert (Cross-Language-HMAC-Kompatibilitaet bestaetigt), ungueltige Signatur und abgelaufenes Token werden korrekt abgelehnt (401), Cookie-Folgeanfrage ohne erneutes Token/Passwort funktioniert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs --- .env.example | 4 ++++ pipe/config.py | 6 ++++++ pipe/server.py | 29 +++++++++++++++++++++++++++-- pipe/testzugang.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 pipe/testzugang.py diff --git a/.env.example b/.env.example index 6a34369..c2d13dd 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,10 @@ LEITSTAND_PORT=8088 LEITSTAND_USER=praxis LEITSTAND_PASS= +# Optional: geteiltes Geheimnis fuer signierte, 1h gueltige Testzugangslinks +# (z.B. per Chatbot-Kommando ausgegeben). Leer = Feature aus. +TESTZUGANG_SECRET= + # --- Telefonanlage (nur Stufe 2) --- # Event-Socket-Port von FreeSWITCH. Muss zu listen-port in # autoload_configs/event_socket.conf.xml passen, sonst antwortet fs_cli nicht diff --git a/pipe/config.py b/pipe/config.py index 0f0741e..c62176b 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -106,6 +106,12 @@ LEITSTAND_PORT = int(os.environ.get("LEITSTAND_PORT", "8088")) LEITSTAND_USER = os.environ.get("LEITSTAND_USER", "praxis") LEITSTAND_PASS = os.environ.get("LEITSTAND_PASS", "") +# Geteiltes Geheimnis mit dilles-agent.php ("agent-one test"-Kommando auf +# dillenberg.net) fuer signierte, 1h gueltige Testzugangslinks - siehe +# pipe.testzugang. Leer lassen deaktiviert das Feature (kein Fallback-Wert, +# ein leeres Secret darf niemals gueltige Signaturen erzeugen). +TESTZUGANG_SECRET = os.environ.get("TESTZUGANG_SECRET", "") + def leitstand_oeffentlich() -> bool: return LEITSTAND_HOST not in {"127.0.0.1", "localhost", "::1"} diff --git a/pipe/server.py b/pipe/server.py index e277b32..977f947 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -19,11 +19,12 @@ import time import urllib.error import urllib.request from datetime import datetime +from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse -from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, store +from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, store, testzugang # Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern, # damit haeufiges Nachfragen den Rechner nicht belastet. @@ -237,9 +238,27 @@ class Handler(BaseHTTPRequestHandler): Ohne Passwort bleibt der Leitstand offen - das ist nur zulässig, wenn er an 127.0.0.1 hängt; darauf besteht main() beim Start. + + Zusätzlich akzeptiert: ein gültiges signiertes Testzugangs-Token + (siehe pipe.testzugang) - per ?zugang=... in der URL (so kommt es aus + dilles-agent.php's "agent-one test"-Kommando) oder per zugang-Cookie + für Folgeanfragen (fetch()-Aufrufe der Seite tragen die URL nicht + mit). Ein frisches URL-Token setzt das Cookie für den Rest der Stunde. """ + self._zusatz_header: list[tuple[str, str]] = [] if not config.LEITSTAND_PASS: return True + + token = parse_qs(urlparse(self.path).query).get("zugang", [None])[0] + if token and testzugang.gueltig(token): + rest_s = max(0, int(token.split(".", 1)[0]) - int(time.time())) + self._zusatz_header.append( + ("Set-Cookie", f"zugang={token}; Max-Age={rest_s}; Path=/; HttpOnly; Secure; SameSite=Lax")) + return True + cookie_wert = SimpleCookie(self.headers.get("Cookie", "")).get("zugang") + if cookie_wert and testzugang.gueltig(cookie_wert.value): + return True + kopf = self.headers.get("Authorization", "") if not kopf.startswith("Basic "): return False @@ -262,6 +281,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", typ) self.send_header("Content-Length", str(len(inhalt))) self.send_header("Cache-Control", "no-store") + for schluessel, wert in getattr(self, "_zusatz_header", []): + self.send_header(schluessel, wert) self.end_headers() self.wfile.write(inhalt) @@ -395,6 +416,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(groesse)) self.send_header("Cache-Control", "no-store") + for schluessel, wert in getattr(self, "_zusatz_header", []): + self.send_header(schluessel, wert) self.end_headers() with ziel.open("rb") as datei: self.wfile.write(datei.read()) @@ -418,6 +441,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Range", f"bytes {start}-{ende}/{groesse}") self.send_header("Content-Length", str(laenge)) self.send_header("Cache-Control", "no-store") + for schluessel, wert in getattr(self, "_zusatz_header", []): + self.send_header(schluessel, wert) self.end_headers() with ziel.open("rb") as datei: datei.seek(start) diff --git a/pipe/testzugang.py b/pipe/testzugang.py new file mode 100644 index 0000000..381c60b --- /dev/null +++ b/pipe/testzugang.py @@ -0,0 +1,35 @@ +"""Signierte, zeitlich begrenzte Testzugangs-Links zum Leitstand. + +Gegenstueck zu dilles_agent_one_test_command() in dillenberg.net's +dilles-agent.php ("agent-one test"-Kommando im Alice-Chat). Beide Seiten +teilen sich TESTZUGANG_SECRET (config.py) - der Link wird komplett auf der +PHP-Seite erzeugt, ohne Live-Aufruf hierher; hier wird nur die Signatur +geprueft. Stateless: kein Server-seitiger Speicher fuer ausgegebene Tokens, +die Gueltigkeit steckt komplett im signierten Ablauf-Zeitstempel. + +Token-Format: "." +""" +from __future__ import annotations + +import hmac +import time +from hashlib import sha256 + +from . import config + + +def gueltig(token: str) -> bool: + """Prueft ein Token aus ?zugang=... oder dem zugang-Cookie. Wirft nie.""" + if not config.TESTZUGANG_SECRET or not token: + return False + try: + ablauf_str, signatur = token.split(".", 1) + ablauf = int(ablauf_str) + except (ValueError, AttributeError): + return False + erwartet = hmac.new( + config.TESTZUGANG_SECRET.encode("utf-8"), ablauf_str.encode("utf-8"), sha256 + ).hexdigest() + if not hmac.compare_digest(erwartet, signatur): + return False + return time.time() < ablauf From 7dcb90826cf351b4bd7816a999b060507685e2e2 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:47:03 +0200 Subject: [PATCH 02/26] feat: Sicher-Modus bei Testzugang erzwungen, nicht abschaltbar Wer per "agent-one test"-Link kommt, braucht kein Passwort - also duerfen fremde Tester keine echten Rufnummern sehen. /api/status liefert jetzt "testzugang" (true, wenn die Anfrage per Zugangs-Token statt Passwort authentifiziert wurde), das Frontend erzwingt darauf sicherSetzen(true) und macht den Sicher-Modus-Button zum No-Op (Toast statt Abschalten). Mit Playwright verifiziert: Laden mit gueltigem Token setzt Sicher-Modus sofort, Klick auf den Button aendert daran nichts. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs --- pipe/server.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/pipe/server.py b/pipe/server.py index 977f947..19e2d5d 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -246,6 +246,7 @@ class Handler(BaseHTTPRequestHandler): mit). Ein frisches URL-Token setzt das Cookie für den Rest der Stunde. """ self._zusatz_header: list[tuple[str, str]] = [] + self._via_token = False # True nur bei Zugang per Testzugangs-Token, nicht Passwort if not config.LEITSTAND_PASS: return True @@ -254,9 +255,11 @@ class Handler(BaseHTTPRequestHandler): rest_s = max(0, int(token.split(".", 1)[0]) - int(time.time())) self._zusatz_header.append( ("Set-Cookie", f"zugang={token}; Max-Age={rest_s}; Path=/; HttpOnly; Secure; SameSite=Lax")) + self._via_token = True return True cookie_wert = SimpleCookie(self.headers.get("Cookie", "")).get("zugang") if cookie_wert and testzugang.gueltig(cookie_wert.value): + self._via_token = True return True kopf = self.headers.get("Authorization", "") @@ -298,7 +301,11 @@ class Handler(BaseHTTPRequestHandler): if pfad == "/": self._sende(SEITE.encode("utf-8"), "text/html; charset=utf-8") elif pfad == "/api/status": - self._json(status()) + daten = status() + # Testzugang (per "agent-one test"-Link) -> Sicher-Modus im + # Frontend erzwingen, siehe sicherSetzen() im JS. + daten["testzugang"] = getattr(self, "_via_token", False) + self._json(daten) elif pfad == "/api/anrufe": self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()}) elif pfad == "/api/verlauf": @@ -820,6 +827,13 @@ setInterval(()=>{if(notfallOffenVorher)piepton()},60000); function zeichneStatus(s){ if(!s)return; + // Testzugang (per "agent-one test"-Link, kein Passwort) -> Sicher-Modus + // erzwingen, nicht abschaltbar. Nur einmal reagieren, wenn der Status das + // erste Mal als Testzugang markiert ankommt. + if(s.testzugang&&!sicherErzwungen){ + sicherErzwungen=true; + sicherSetzen(true); + } document.getElementById('stand').textContent='Stand '+s.stand; document.getElementById('dienste').innerHTML=s.dienste.map(d=> `
@@ -958,14 +972,21 @@ const meldung=(()=>{ // Sicher-Modus merkt sich den Zustand pro Browser (localStorage) - beim // naechsten Aufruf (z.B. vor einer Bildschirmaufnahme) nicht vergessen. +// Bei Testzugang (per "agent-one test"-Link, s. api/status.testzugang) ist +// er erzwungen an und laesst sich nicht abschalten - der Link braucht kein +// Passwort, also duerfen fremde Tester keine echten Rufnummern sehen. const sicherBtn=document.getElementById('sicherBtn'); +let sicherErzwungen=false; function sicherSetzen(an){ document.body.classList.toggle('sicher',an); sicherBtn.classList.toggle('aktiv',an); try{localStorage.setItem('sicherModus',an?'1':'0')}catch(e){} } try{sicherSetzen(localStorage.getItem('sicherModus')==='1')}catch(e){} -sicherBtn.onclick=()=>sicherSetzen(!document.body.classList.contains('sicher')); +sicherBtn.onclick=()=>{ + if(sicherErzwungen){toast('Sicher-Modus ist bei Testzugang immer aktiv');return} + sicherSetzen(!document.body.classList.contains('sicher')); +}; // Kategorisierungs-Modell (Dropdown oben rechts) - Wechsel greift sofort // beim naechsten Anruf, kein Neustart des Watchers noetig (siehe From abf54765d83470538cf3dc087e7e2189abf35bcc Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:11:41 +0200 Subject: [PATCH 03/26] fix: Rufnummern bei Testzugang serverseitig maskieren, nicht nur per CSS blurren CSS-Blur (Sicher-Modus) verdeckt nur die Anzeige - im Seitenquelltext, DevTools oder Netzwerk-Tab standen die echten Rufnummern trotzdem drin, weil sie ganz normal im JSON von /api/anrufe und /api/verlauf ankamen. Bei Testzugang (per Token statt Passwort, self._via_token) werden nummer/ rueckrufnummer jetzt vor dem Senden durch einen Platzhalter ersetzt (_maskiere_anrufe), im Verlauf-Log werden eingebettete Ziffernfolgen ab 9 Stellen ersetzt (_maskiere_verlauf, gleiche Heuristik wie das bestehende Frontend-telBlur, nur serverseitig und wirklich ersetzend statt nur verdeckend). Die echten Werte verlassen den Server in diesem Modus nicht mehr. Getestet: 13 unmaskierte Rufnummern im Verlauf bei Passwort-Zugriff, 0 bei Token-Zugriff; _maskiere_anrufe() isoliert verifiziert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs --- pipe/server.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/pipe/server.py b/pipe/server.py index 19e2d5d..1148102 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -229,6 +229,30 @@ def verlauf(anzahl: int = 150) -> list[dict]: return alle[-anzahl:] +# Bei Testzugang (kein Passwort, oeffentlicher Link) werden Rufnummern nicht +# nur im Frontend per CSS geblurred (das verdeckt nur die Anzeige - im +# Seitenquelltext/DevTools/Netzwerk-Tab stehen sie trotzdem echt drin), +# sondern schon hier serverseitig ersetzt. Die echten Werte verlassen den +# Server in diesem Modus gar nicht erst. +_MASKE = "•••• •••• •••" + + +def _maskiere_anrufe(anrufe_liste: list[dict]) -> list[dict]: + for a in anrufe_liste: + if a.get("nummer"): + a["nummer"] = _MASKE + if a.get("rueckrufnummer"): + a["rueckrufnummer"] = _MASKE + return anrufe_liste + + +def _maskiere_verlauf(eintraege: list[dict]) -> list[dict]: + for e in eintraege: + if e.get("text"): + e["text"] = re.sub(r"\d{9,}", "•••••••••", e["text"]) + return eintraege + + class Handler(BaseHTTPRequestHandler): def log_message(self, *_): # kein Zugriffslog auf der Konsole pass @@ -307,9 +331,15 @@ class Handler(BaseHTTPRequestHandler): daten["testzugang"] = getattr(self, "_via_token", False) self._json(daten) elif pfad == "/api/anrufe": - self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()}) + liste = anrufe() + if getattr(self, "_via_token", False): + liste = _maskiere_anrufe(liste) + self._json({"stapel": stapel.erlaubt(), "anrufe": liste}) elif pfad == "/api/verlauf": - self._json(verlauf(150)) + eintraege = verlauf(150) + if getattr(self, "_via_token", False): + eintraege = _maskiere_verlauf(eintraege) + self._json(eintraege) elif pfad == "/api/modell": self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.AUSWAHL}) elif pfad.startswith("/audio/"): From 441883a0731974c5bfc3844ef3f77986463e5442 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:14:15 +0200 Subject: [PATCH 04/26] feat: LinkedIn und Telefonnummer im Footer Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs --- pipe/server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pipe/server.py b/pipe/server.py index 1148102..9c687ea 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -658,6 +658,13 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight: 1.36.87 2.29 0 3.28-2 4-3.9 4.22.31.27.58.79.58 1.6 0 1.15-.01 2.08-.01 2.36 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> Projekt auf GitHub + + + LinkedIn + + 📞 0152 23062462 ☕ Unterstützen
From de9c269e9c1e6130a640e55b100b597f40c20745 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:15:19 +0200 Subject: [PATCH 05/26] fix: Telefonnummer im Footer im internationalen Format anzeigen Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs --- pipe/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipe/server.py b/pipe/server.py index 9c687ea..73cbb79 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -664,7 +664,7 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight: 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z"/> LinkedIn - 📞 0152 23062462 + 📞 +49 152 23062462 ☕ Unterstützen From be0252c907549b11b6f9475563136f2bfa6d8f3f Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:19:03 +0200 Subject: [PATCH 06/26] feat: Nextcloud-Profil im Footer Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs --- pipe/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pipe/server.py b/pipe/server.py index 73cbb79..a7b092e 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -665,6 +665,7 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight: LinkedIn 📞 +49 152 23062462 + ☁️ Nextcloud ☕ Unterstützen From 6458c36d852e72041ca033e197d30f4fcb5ce381 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:32:44 +0200 Subject: [PATCH 07/26] img fix --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 32aa600..ea633ef 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ ab (lokal und optional in Nextcloud). **Vollständig on-prem** — keine Patientendaten verlassen den Rechner. -![Der Leitstand im Dunkelmodus: Dienste-Ampeln und Verlauf oben, darunter das Board mit Eingang, In Bearbeitung, Rückfragen und Erledigt](docs/agentone-screen-1.jpg) > **Hinweis:** Alle Namen, Rufnummern und Anliegen in diesem Repository sind > erfunden — auch im Screenshot und in der Beispielkonfiguration. „Praxis From 09be478868e9b9463eec5b98956706a0d7db719b Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:45:25 +0200 Subject: [PATCH 08/26] Update README for Linux setup instructions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea633ef..f5ff1d4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ab (lokal und optional in Nextcloud). > Musterhausen" ist ein Platzhalter. Echte Zugangsdaten und Anrufdaten liegen > außerhalb des Repositorys (`.env`, `ablage/`, `telefon/`). -Wer das Projekt auf einem weiteren Rechner einrichtet: [docs/MITGABE.md](docs/MITGABE.md) +Wer das Projekt auf einem Linux einrichten möchte, eine Kleine Hilfe: [docs/MITGABE.md](docs/MITGABE.md) sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. ## Stufen From 6e5fe59c53b5cd6368a1f5fa163b605427ea869c Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:47:45 +0200 Subject: [PATCH 09/26] =?UTF-8?q?feat:=20St=C3=B6rungswache=20(pipe.monito?= =?UTF-8?q?r)=20+=20Verschl=C3=BCsselung-at-rest=20dokumentiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dienste-Ampel (Telefon, Verarbeitung, Spracherkennung, Nextcloud) alarmierte bisher nur im offenen Leitstand. pipe/monitor.py laeuft jetzt als eigener Dauerprozess, prueft dieselbe Ampel (neu ausgelagert nach pipe/dienste.py) periodisch und alarmiert bei Stoerung lokal per macOS-Benachrichtigung + Ton (pipe/alarm.py) - unabhaengig davon, ob pipe.watch oder pipe.server laufen. Wird ab jetzt von telefon/starten.sh mitgestartet. Verschluesselung at rest: keine App-eigene Verschluesselung eingebaut, stattdessen im README als Betreiber-Pflicht dokumentiert (FileVault aktivieren) - App verlaesst sich auf Festplattenverschluesselung durch das Betriebssystem. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- .env.example | 8 ++++ README.md | 46 +++++++++++++++++++++- pipe/alarm.py | 27 +++++++++++++ pipe/config.py | 13 +++++++ pipe/dienste.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++ pipe/monitor.py | 70 +++++++++++++++++++++++++++++++++ pipe/server.py | 90 +------------------------------------------ telefon/starten.sh | 6 +++ 8 files changed, 266 insertions(+), 90 deletions(-) create mode 100644 pipe/alarm.py create mode 100644 pipe/dienste.py create mode 100644 pipe/monitor.py diff --git a/.env.example b/.env.example index c2d13dd..cf413a0 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,14 @@ NEXTCLOUD_ADRESSBUCH=contacts # Anrufe bleiben unangetastet, egal wie alt. LOESCHFRIST_TAGE=0 +# --- Störungswache (pipe.monitor, eigener Prozess, siehe telefon/starten.sh) --- +# Prüft die Dienste-Ampel (Telefon, Verarbeitung, Spracherkennung, Nextcloud) +# und alarmiert lokal per macOS-Benachrichtigung + Ton, wenn niemand auf den +# Leitstand schaut. +MONITOR_TAKT_S=60 +MONITOR_WIEDERHOLUNG_MIN=15 +MONITOR_TON=Sosumi + # --- Telefonie (SIP-Zugang der Praxis, leer lassen = keine Telefonannahme) --- SIP_USER= SIP_DOMAIN=sip.plusnet.de diff --git a/README.md b/README.md index 32aa600..7e33acc 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,10 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. 1. **Die Pipe** (fertig): Audio → Transkript → Kategorie → Ablage. 2. **Telefonie** (steht): FreeSWITCH nimmt über den Plusnet-Trunk an, spielt die Ansage, nimmt auf und legt die Datei im Eingang der Pipe ab. -3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅ — offen: - Monitoring/Alerting bei Diensteausfall, Verschlüsselung at rest. +3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅, + Verschlüsselung at rest ✅ (Betreiber-Pflicht: FileVault, siehe + [Datenschutz](#datenschutz-dsgvo)), Monitoring/Alerting bei Diensteausfall ✅ + (`pipe/monitor.py`, siehe unten). ## Bausteine @@ -37,6 +39,9 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. | `pipe/config.py` | Konfiguration aus `.env` | | `pipe/watch.py` | Beobachtet den Eingang und schiebt Aufnahmen durch die Pipe | | `pipe/server.py` | Leitstand: Live-Ansicht mit Status, Anrufen und Verlauf | +| `pipe/dienste.py` | Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung, Nextcloud) — geteilt von Leitstand und Störungswache | +| `pipe/monitor.py` | Störungswache: alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt | +| `pipe/alarm.py` | Verschickt den lokalen macOS-Alarm (osascript) | | `pipe/protokoll.py` | Ereignisprotokoll (JSONL) für den Leitstand | | `pipe/audit.py` | Unveränderliches Audit-Log (append-only, siehe unten) | | `pipe/loeschen.py` | Löscht erledigte Anrufe nach Ablauf der Aufbewahrungsfrist | @@ -247,6 +252,29 @@ in `telefon/audit.log` — anders als `verlauf.log` (fürs Leitstand-UI, wird au zusätzlich per macOS `chflags uappnd` gegen nachträgliches Ändern/Kürzen abgesichert (auch der eigene Prozess kommt danach nur noch per Anhängen rein). +## Störungswache (Monitoring/Alerting) + +Die Dienste-Ampel im Leitstand (Telefon, Verarbeitung, Spracherkennung, +Nextcloud) zeigt den Zustand nur an, wenn gerade jemand die Seite offen hat. +`pipe/monitor.py` läuft deshalb als **eigener Prozess** (von +`telefon/starten.sh` mitgestartet, Log `telefon/monitor.log`), fragt dieselbe +Ampel (`pipe/dienste.py`) alle `MONITOR_TAKT_S` Sekunden (Default 60) ab und +alarmiert bei einer echten Störung (`schlecht`/`aus` — nicht bei `unklar`, +z. B. Nextcloud unkonfiguriert) lokal per macOS-Systembenachrichtigung + Ton +(`pipe/alarm.py`, `osascript`). Solange die Störung anhält, wird alle +`MONITOR_WIEDERHOLUNG_MIN` Minuten (Default 15) erneut erinnert; bei +Wiederherstellung einmalig „wieder ok". + +Bewusst ein eigener, dritter Prozess statt in `pipe.watch` oder `pipe.server` +eingebaut: fällt einer der beiden aus, kann die Wache das trotzdem noch +melden. Wirkt nur lokal auf diesem Mac — für Alarme aufs Handy (z. B. per +Push/E-Mail) `pipe/alarm.py` erweitern. + +```bash +python3 -m pipe.monitor # dauerhaft beobachten (normalerweise per starten.sh) +python3 -m pipe.monitor --einmal # einmal prüfen, für Tests +``` + ## Bewertung & Trainingsdaten-Export Jede Karte im Leitstand hat 👍/👎 — war die Kategorisierung richtig? Bei 👎 @@ -275,3 +303,17 @@ Gesundheitsdaten — Pflichten für den Produktivbetrieb (Stufe 2): > beiden Punkte noch nicht (kein Notfall-Hinweis, keine Aufnahme-Einwilligung) > — Text anpassen und `ansage_bauen.sh` neu laufen lassen, bevor die Anlage im > echten Praxisbetrieb ans Netz geht. + +> **Verschlüsselung at rest — Pflicht des Betreibers:** Die Pipe legt +> Aufnahmen, Transkripte und Metadaten (`ablage/`, `telefon/`, `training/`) +> unverschlüsselt auf der Platte ab und verlässt sich auf +> Festplattenverschlüsselung durch das Betriebssystem. **FileVault muss auf +> jedem Rechner aktiviert sein, der diese Pipe betreibt** +> (Systemeinstellungen → Datenschutz & Sicherheit → FileVault, oder +> `sudo fdesetup enable`) — den Recovery-Key sicher verwahren (Passwort- +> Tresor), da er das einzige Mittel bei vergessenem Login-Passwort ist. +> Ist FileVault auf einem Zielsystem nicht aktivierbar oder nicht zulässig +> (z. B. Mehrbenutzer-Rechner, andere Auflagen), reicht das nicht aus — dann +> braucht die App eine eigene Verschlüsselung der Ablage, die es aktuell +> **nicht** gibt. Das Aktivieren/Prüfen von FileVault ist Aufgabe des +> Betreibers vor Ort, nicht Teil dieses Repos. diff --git a/pipe/alarm.py b/pipe/alarm.py new file mode 100644 index 0000000..a19ac2f --- /dev/null +++ b/pipe/alarm.py @@ -0,0 +1,27 @@ +"""Lokaler macOS-Alarm (Systembenachrichtigung + Ton) für pipe.monitor. + +Wirft nie - eine fehlgeschlagene Benachrichtigung darf die Wache nicht zum +Absturz bringen. Nutzt osascript (stdlib-Unterprozess, kein Framework, keine +Abhängigkeit von einem laufenden Nextcloud/Cloud-Dienst). +""" +from __future__ import annotations + +import subprocess + +from . import config + + +def _quote(text: str) -> str: + """AppleScript-String-Literal - Anführungszeichen/Backslashes escapen.""" + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def sende(titel: str, text: str) -> None: + skript = ( + f"display notification {_quote(text)} with title {_quote(titel)} " + f"sound name {_quote(config.MONITOR_TON)}" + ) + try: + subprocess.run(["osascript", "-e", skript], capture_output=True, timeout=10) + except Exception as fehler: + print(f" Alarm konnte nicht zugestellt werden: {fehler}") diff --git a/pipe/config.py b/pipe/config.py index c62176b..fb46e4b 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -168,6 +168,19 @@ def nextcloud_aktiv() -> bool: return bool(NEXTCLOUD_URL and NEXTCLOUD_USER and NEXTCLOUD_PASS) +# --- Störungswache (pipe.monitor) -------------------------------------------- +# Prueft die Dienste-Ampel (pipe.dienste: Telefon, Verarbeitung, Spracherkennung, +# Nextcloud) periodisch und alarmiert lokal per macOS-Benachrichtigung + Ton, +# wenn niemand auf den Leitstand schaut. Eigener Prozess (siehe +# telefon/starten.sh) - faellt pipe.watch oder pipe.server aus, meldet die +# Wache das trotzdem noch. +MONITOR_TAKT_S = int(os.environ.get("MONITOR_TAKT_S", "60") or "60") +# Erinnerungsabstand, waehrend eine Stoerung anhaelt - sonst nur ein einziger +# Alarm beim Ausfall, der leicht untergeht. +MONITOR_WIEDERHOLUNG_MIN = int(os.environ.get("MONITOR_WIEDERHOLUNG_MIN", "15") or "15") +# macOS-Systemton (siehe /System/Library/Sounds), der bei jedem Alarm abgespielt wird. +MONITOR_TON = os.environ.get("MONITOR_TON", "Sosumi").strip() or "Sosumi" + # --- Aufbewahrungsfrist (pipe.loeschen) -------------------------------------- # Nach so vielen Tagen wird ein Anruf-Ordner (Aufnahme + Transkript + Meta) # geloescht - lokal und, falls hochgeladen, auch bei Nextcloud. 0 = aus (nichts diff --git a/pipe/dienste.py b/pipe/dienste.py new file mode 100644 index 0000000..c17342f --- /dev/null +++ b/pipe/dienste.py @@ -0,0 +1,96 @@ +"""Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung, +Nextcloud) — geteilt zwischen Leitstand (pipe.server) und Störungswache +(pipe.monitor), damit beide dieselbe Sicht auf "läuft/steht" haben. +""" +from __future__ import annotations + +import subprocess +import time +import urllib.error +import urllib.request +from datetime import datetime + +from . import config, store + +# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern, +# damit haeufiges Nachfragen den Rechner nicht belastet. +_CACHE: dict[str, tuple[float, dict]] = {} +CACHE_S = 4.0 + + +def _laeuft(muster: str) -> bool: + try: + return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0 + except Exception: + return False + + +def _http_ok(url: str, timeout: float = 5.0) -> bool: + """Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud + blockt den Standardnamen von urllib und meldete fälschlich 'nicht + erreichbar'.""" + req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"}) + try: + with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()): + return True + except urllib.error.HTTPError: + return True # antwortet - reicht als Lebenszeichen + except Exception: + return False + + +def _trunk() -> tuple[str, str]: + """(zustand, text) des SIP-Trunks.""" + if not _laeuft("[f]reeswitch"): + return "aus", "FreeSWITCH läuft nicht" + try: + roh = subprocess.run( + ["fs_cli", "-P", config.FS_PORT, "-x", "sofia status gateway plusnet"], + capture_output=True, text=True, timeout=8).stdout + for zeile in roh.splitlines(): + if zeile.startswith("Status"): + wert = zeile.split()[-1] + return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}") + return "unklar", "Gateway unbekannt" + except Exception: + return "unklar", "fs_cli nicht erreichbar" + + +def status() -> dict: + jetzt = time.time() + gepuffert = _CACHE.get("status") + if gepuffert and jetzt - gepuffert[0] < CACHE_S: + return gepuffert[1] + + trunk_zustand, trunk_text = _trunk() + watcher = _laeuft("[p]ipe.watch") + ollama = _http_ok(f"{config.OLLAMA_URL}/api/version") + nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php") + if config.nextcloud_aktiv() else None) + + offen = 0 + if config.TELEFON_EINGANG.is_dir(): + offen = sum(1 for d in config.TELEFON_EINGANG.iterdir() + if d.is_file() and not d.name.startswith(".")) + fehler = 0 + if config.TELEFON_FEHLER.is_dir(): + fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file()) + + daten = { + "dienste": [ + {"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text}, + {"name": "Verarbeitung", "zustand": "gut" if watcher else "aus", + "text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"}, + {"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht", + "text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"}, + {"name": "Nextcloud", "zustand": "unklar" if nextcloud is None + else ("gut" if nextcloud else "schlecht"), + "text": "nicht konfiguriert" if nextcloud is None + else ("erreichbar" if nextcloud else "nicht erreichbar")}, + ], + "eingang_offen": offen, + "fehler": fehler, + "stand": datetime.now().strftime("%H:%M:%S"), + } + _CACHE["status"] = (jetzt, daten) + return daten diff --git a/pipe/monitor.py b/pipe/monitor.py new file mode 100644 index 0000000..b60bcf4 --- /dev/null +++ b/pipe/monitor.py @@ -0,0 +1,70 @@ +"""Störungswache: prüft die Dienste-Ampel (pipe.dienste) periodisch und +alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt - +auch wenn gerade niemand auf den Leitstand schaut. + + python3 -m pipe.monitor [--einmal] + +Läuft als eigener Prozess (siehe telefon/starten.sh), unabhängig von +pipe.watch und pipe.server - fällt einer der beiden aus, kann die Wache das +trotzdem noch melden. Alarmiert nur bei echten Störungen ("schlecht"/"aus"), +nicht bei "unklar" (z. B. Nextcloud nicht konfiguriert - das ist gewollt, +kein Fehler). +""" +from __future__ import annotations + +import argparse +import time + +from . import alarm, config, dienste + +GESTOERT = {"schlecht", "aus"} + + +def pruefe(letzte_alarme: dict[str, float]) -> None: + """Ein Durchlauf: Ampel abfragen, bei Bedarf alarmieren. + + `letzte_alarme` hält je Dienstname den Zeitpunkt (time.monotonic) des + letzten Alarms - fehlt ein Name, gilt der Dienst aktuell als nicht + gestört. Der Aufrufer reicht dasselbe dict über alle Durchläufe weiter. + """ + jetzt = time.monotonic() + for dienst in dienste.status()["dienste"]: + name, zustand, text = dienst["name"], dienst["zustand"], dienst["text"] + letzter_alarm = letzte_alarme.get(name) + if zustand in GESTOERT: + if letzter_alarm is None: + alarm.sende(f"⚠ {name} gestört", text) + letzte_alarme[name] = jetzt + elif jetzt - letzter_alarm >= config.MONITOR_WIEDERHOLUNG_MIN * 60: + alarm.sende(f"⚠ {name} weiterhin gestört", text) + letzte_alarme[name] = jetzt + elif zustand == "gut" and letzter_alarm is not None: + alarm.sende(f"✓ {name} wieder ok", text) + del letzte_alarme[name] + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Dienste-Ampel beobachten und bei Störung lokal alarmieren.") + p.add_argument("--einmal", action="store_true", + help="nur einmal prüfen statt dauerhaft zu beobachten") + args = p.parse_args(argv) + + letzte_alarme: dict[str, float] = {} + if args.einmal: + pruefe(letzte_alarme) + return 0 + + print(f"Störungswache aktiv - Takt {config.MONITOR_TAKT_S}s, " + f"Erinnerung alle {config.MONITOR_WIEDERHOLUNG_MIN} min - Abbruch mit Strg-C") + try: + while True: + pruefe(letzte_alarme) + time.sleep(config.MONITOR_TAKT_S) + except KeyboardInterrupt: + print("\nBeobachtung beendet.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pipe/server.py b/pipe/server.py index a7b092e..eb2f3fb 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -13,101 +13,15 @@ import base64 import hmac import json import re -import subprocess import sys import time -import urllib.error -import urllib.request from datetime import datetime from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse -from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, store, testzugang - -# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern, -# damit haeufiges Nachfragen den Rechner nicht belastet. -_CACHE: dict[str, tuple[float, dict]] = {} -CACHE_S = 4.0 - - -def _laeuft(muster: str) -> bool: - try: - return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0 - except Exception: - return False - - -def _http_ok(url: str, timeout: float = 5.0) -> bool: - """Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud - blockt den Standardnamen von urllib und meldete fälschlich 'nicht - erreichbar'.""" - req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"}) - try: - with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()): - return True - except urllib.error.HTTPError: - return True # antwortet - reicht als Lebenszeichen - except Exception: - return False - - -def _trunk() -> tuple[str, str]: - """(zustand, text) des SIP-Trunks.""" - if not _laeuft("[f]reeswitch"): - return "aus", "FreeSWITCH läuft nicht" - try: - roh = subprocess.run( - ["fs_cli", "-P", config.FS_PORT, "-x", "sofia status gateway plusnet"], - capture_output=True, text=True, timeout=8).stdout - for zeile in roh.splitlines(): - if zeile.startswith("Status"): - wert = zeile.split()[-1] - return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}") - return "unklar", "Gateway unbekannt" - except Exception: - return "unklar", "fs_cli nicht erreichbar" - - -def status() -> dict: - jetzt = time.time() - gepuffert = _CACHE.get("status") - if gepuffert and jetzt - gepuffert[0] < CACHE_S: - return gepuffert[1] - - trunk_zustand, trunk_text = _trunk() - watcher = _laeuft("[p]ipe.watch") - ollama = _http_ok(f"{config.OLLAMA_URL}/api/version") - nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php") - if config.nextcloud_aktiv() else None) - - offen = 0 - if config.TELEFON_EINGANG.is_dir(): - offen = sum(1 for d in config.TELEFON_EINGANG.iterdir() - if d.is_file() and not d.name.startswith(".")) - fehler = 0 - if config.TELEFON_FEHLER.is_dir(): - fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file()) - - daten = { - "dienste": [ - {"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text}, - {"name": "Verarbeitung", "zustand": "gut" if watcher else "aus", - "text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"}, - {"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht", - "text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"}, - {"name": "Nextcloud", "zustand": "unklar" if nextcloud is None - else ("gut" if nextcloud else "schlecht"), - "text": "nicht konfiguriert" if nextcloud is None - else ("erreichbar" if nextcloud else "nicht erreichbar")}, - ], - "eingang_offen": offen, - "fehler": fehler, - "stand": datetime.now().strftime("%H:%M:%S"), - } - _CACHE["status"] = (jetzt, daten) - return daten +from . import bewertung, config, dashboard, dienste, export, modellwahl, protokoll, stapel, testzugang def anrufe() -> list[dict]: @@ -325,7 +239,7 @@ class Handler(BaseHTTPRequestHandler): if pfad == "/": self._sende(SEITE.encode("utf-8"), "text/html; charset=utf-8") elif pfad == "/api/status": - daten = status() + daten = dienste.status() # Testzugang (per "agent-one test"-Link) -> Sicher-Modus im # Frontend erzwingen, siehe sicherSetzen() im JS. daten["testzugang"] = getattr(self, "_via_token", False) diff --git a/telefon/starten.sh b/telefon/starten.sh index 5bc2b82..17e3141 100755 --- a/telefon/starten.sh +++ b/telefon/starten.sh @@ -109,6 +109,12 @@ if [ "${status:-}" = "UP" ]; then sleep 2 head -1 telefon/leitstand.log fi + if pgrep -f "[p]ipe.monitor" >/dev/null; then + echo "Stoerungswache laeuft bereits." + else + nohup python3 -u -m pipe.monitor > telefon/monitor.log 2>&1 & + echo "Stoerungswache gestartet (Log: telefon/monitor.log)" + fi sleep 2 echo ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) From d39b360b7b005908bd057e6924653b4a2ffe7bae Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:53:45 +0200 Subject: [PATCH 10/26] feat: Modell-Dropdown listet lokale Ollama-Modelle live statt fest im Code Bisher stand nur config.OLLAMA_MODELL zur Auswahl - ein neu per "ollama pull" installiertes Modell (z.B. ornith-1.5:9b) tauchte nicht im Leitstand auf, ohne Code-Aenderung. modellwahl.auswahl() fragt jetzt GET /api/tags live ab und baut die Ollama-Optionen daraus; reine Embedding-Modelle (capabilities ohne "completion", z.B. nomic-embed-text) werden ausgefiltert, weil sie nicht kategorisieren koennen. Die festen OpenRouter-Testeintraege bleiben bestehen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/modellwahl.py | 106 ++++++++++++++++++++++++++++++++++----------- pipe/server.py | 2 +- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/pipe/modellwahl.py b/pipe/modellwahl.py index 0cef52b..d79eff2 100644 --- a/pipe/modellwahl.py +++ b/pipe/modellwahl.py @@ -5,49 +5,104 @@ Der Watcher (pipe.watch, Langzeit-Prozess) und der Leitstand (pipe.server, eigener Prozess) laufen getrennt - die Auswahl wird deshalb in einer kleinen JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein Caching), damit eine Umschaltung sofort beim naechsten Anruf greift. + +Die lokalen Ollama-Modelle werden live von Ollama abgefragt (GET /api/tags) +statt fest im Code zu stehen - jedes per "ollama pull" installierte Modell +taucht automatisch im Dropdown auf, ohne Code-Aenderung. Eine feste +OpenRouter-Testauswahl bleibt daneben bestehen: die Modell-IDs dort lassen +sich nicht lokal verifizieren, ein Tippfehler wuerde sonst erst beim naechsten +Anruf sichtbar - fuer freie Eingabe siehe OPENROUTER_MODELL in der .env. """ from __future__ import annotations import json +import time +import urllib.error +import urllib.request from . import config DATEI = config.TELEFON / "modell.json" -# Feste, kuratierte Auswahl statt freier Eingabe - ein Tippfehler in einer -# Modell-ID im Leitstand darf nicht den naechsten Anruf scheitern lassen. -# "openrouter"-Eintraege sind zum Testen gedacht (Transkript verlaesst dabei -# den Rechner) - nicht fuer den Praxisbetrieb. -AUSWAHL = [ - {"id": "ollama", "label": f"{config.OLLAMA_MODELL} (lokal)", - "backend": "ollama", "modell": config.OLLAMA_MODELL}, - {"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)", - "backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"}, - {"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)", - "backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"}, -] -# Optionaler vierter Eintrag zum schnellen Ausprobieren eines beliebigen -# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in -# .env setzen. -if config.OPENROUTER_MODELL: - AUSWAHL.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)", - "backend": "openrouter", "modell": config.OPENROUTER_MODELL}) -_STANDARD = AUSWAHL[0]["id"] -_NACH_ID = {e["id"]: e for e in AUSWAHL} +# Ollama-Abfrage ist ein Netzwerkaufruf - kurz zwischenspeichern, damit ein +# offener Leitstand (Polling alle paar Sekunden) Ollama nicht dauerbelastet. +_CACHE: dict[str, tuple[float, list[dict]]] = {} +CACHE_S = 30.0 + + +def _ollama_modelle() -> list[str]: + """Namen der lokal installierten, textfähigen Ollama-Modelle (leer bei Fehler). + + Reine Embedding-Modelle (z. B. nomic-embed-text) werden ausgefiltert - sie + können nicht kategorisieren, ein Aufruf würde nur mit einem Fehler enden. + Ältere Ollama-Versionen liefern kein "capabilities"-Feld - dann bleibt das + Modell sicherheitshalber drin, statt es zu Unrecht zu verstecken. + """ + req = urllib.request.Request(f"{config.OLLAMA_URL}/api/tags", + headers={"User-Agent": "praxis-telefon-agent/1.0"}) + try: + with urllib.request.urlopen(req, timeout=5) as antwort: + daten = json.loads(antwort.read().decode("utf-8")) + except Exception: + return [] + namen = [] + for m in daten.get("models", []): + name = m.get("name") + faehigkeiten = m.get("capabilities") + if name and (faehigkeiten is None or "completion" in faehigkeiten): + namen.append(name) + return sorted(namen) + + +def auswahl() -> list[dict]: + """Aktuelle Dropdown-Optionen: lokale Ollama-Modelle live + feste OpenRouter-Testeintraege.""" + jetzt = time.time() + gepuffert = _CACHE.get("auswahl") + if gepuffert and jetzt - gepuffert[0] < CACHE_S: + return gepuffert[1] + + # Ist Ollama gerade nicht erreichbar, bleibt wenigstens die konfigurierte + # Standardauswahl im Dropdown - ein leeres Dropdown waere schlechter. + lokale = _ollama_modelle() or [config.OLLAMA_MODELL] + liste = [{"id": f"ollama:{name}", "label": f"{name} (lokal)", + "backend": "ollama", "modell": name} for name in lokale] + liste.append({"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)", + "backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"}) + liste.append({"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)", + "backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"}) + # Optionaler Eintrag zum schnellen Ausprobieren eines beliebigen + # OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in + # .env setzen. + if config.OPENROUTER_MODELL: + liste.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)", + "backend": "openrouter", "modell": config.OPENROUTER_MODELL}) + + _CACHE["auswahl"] = (jetzt, liste) + return liste + + +def _standard_id(optionen: list[dict]) -> str: + """Bevorzugt das in der .env konfigurierte Modell, sonst die erste Option.""" + for eintrag in optionen: + if eintrag["backend"] == "ollama" and eintrag["modell"] == config.OLLAMA_MODELL: + return eintrag["id"] + return optionen[0]["id"] def aktuelle_id() -> str: """Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard.""" + optionen = auswahl() + standard = _standard_id(optionen) try: wert = json.loads(DATEI.read_text(encoding="utf-8")).get("id") except Exception: - return _STANDARD - return wert if wert in _NACH_ID else _STANDARD + return standard + return wert if wert in {e["id"] for e in optionen} else standard def setze(kennung: str) -> bool: - """Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist.""" - if kennung not in _NACH_ID: + """Schreibt die Auswahl. False, wenn die ID nicht (mehr) vorgesehen ist.""" + if kennung not in {e["id"] for e in auswahl()}: return False try: DATEI.parent.mkdir(parents=True, exist_ok=True) @@ -59,5 +114,6 @@ def setze(kennung: str) -> bool: def backend_und_modell() -> tuple[str, str]: """Backend + Modell-ID der aktiven Auswahl, fuer categorize.py.""" - eintrag = _NACH_ID[aktuelle_id()] + kennung = aktuelle_id() + eintrag = next(e for e in auswahl() if e["id"] == kennung) return eintrag["backend"], eintrag["modell"] diff --git a/pipe/server.py b/pipe/server.py index eb2f3fb..24e50d7 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -255,7 +255,7 @@ class Handler(BaseHTTPRequestHandler): eintraege = _maskiere_verlauf(eintraege) self._json(eintraege) elif pfad == "/api/modell": - self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.AUSWAHL}) + self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.auswahl()}) elif pfad.startswith("/audio/"): self._audio(pfad[len("/audio/"):]) else: From 87621e5beca8e11712e5c3a369d216d35a90b00e Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:07:29 +0200 Subject: [PATCH 11/26] feat: Branchen-Profile - Projekt fuer andere Einsatzzwecke konfigurierbar machen Kategorien, Prompt und Notfall-Sicherheitsnetz waren fest auf eine Hausarztpraxis zugeschnitten (categorize.py: KATEGORIEN-Liste, _NOTFALL_MUSTER- Regex). Fuer einen anderen Einsatzzweck (z.B. 24h-Aufzug-Notdienst) musste bisher Python-Code geaendert werden. Neu: profile// buendelt prompt.txt, kategorien.json, sicherheitsnetz.txt und ansage.txt - alles, was fachlich vom Einsatzzweck abhaengt, an einer Stelle. PROFIL in der .env waehlt den aktiven Ordner (Default "praxis", altes Verhalten bleibt exakt gleich - per Test verifiziert: identische Kategorien- Liste und Sicherheitsnetz-Muster). profile/aufzug-notdienst/ liegt als ausformuliertes Beispielprofil bei (eigene Kategorien, eigenes Sicherheitsnetz, eigene Ansage). Dringlichkeitsstufen (niedrig/normal/hoch/notfall) bleiben bewusst fest - nur die Kategorien sind einsatzzweck-spezifisch. Neues pipe/kategorien.py laedt das Profil-Schema einmal fuer categorize.py, dashboard.py, deck.py und server.py (Leitstand-JS: Kategorie-Label kommen jetzt vom Server statt fest im Frontend zu stehen). prompts/categorize_de.txt und telefon/ansage.txt sind nach profile/praxis/ umgezogen (git erkennt es als Rename). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- .env.example | 6 ++ README.md | 65 +++++++++++++++---- pipe/categorize.py | 61 ++++++++--------- pipe/config.py | 18 ++++- pipe/dashboard.py | 10 +-- pipe/deck.py | 8 +-- pipe/kategorien.py | 33 ++++++++++ pipe/server.py | 19 ++++-- profile/aufzug-notdienst/ansage.txt | 1 + profile/aufzug-notdienst/kategorien.json | 11 ++++ profile/aufzug-notdienst/prompt.txt | 44 +++++++++++++ profile/aufzug-notdienst/sicherheitsnetz.txt | 16 +++++ {telefon => profile/praxis}/ansage.txt | 0 profile/praxis/kategorien.json | 18 +++++ .../praxis/prompt.txt | 0 profile/praxis/sicherheitsnetz.txt | 23 +++++++ telefon/ansage_bauen.sh | 14 ++-- 17 files changed, 277 insertions(+), 70 deletions(-) create mode 100644 pipe/kategorien.py create mode 100644 profile/aufzug-notdienst/ansage.txt create mode 100644 profile/aufzug-notdienst/kategorien.json create mode 100644 profile/aufzug-notdienst/prompt.txt create mode 100644 profile/aufzug-notdienst/sicherheitsnetz.txt rename {telefon => profile/praxis}/ansage.txt (100%) create mode 100644 profile/praxis/kategorien.json rename prompts/categorize_de.txt => profile/praxis/prompt.txt (100%) create mode 100644 profile/praxis/sicherheitsnetz.txt diff --git a/.env.example b/.env.example index cf413a0..60d7470 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ WHISPER_THREADS=8 WHISPER_MODELL=medium WHISPER_COMPUTE=int8 +# --- Branchen-Profil (Kategorien, Prompt, Sicherheitsnetz, Ansage) --- +# Waehlt den Ordner unter profile/ - buendelt alles, was fachlich vom +# Einsatzzweck abhaengt. Mitgeliefert: "praxis" (Hausarztpraxis, Default), +# "aufzug-notdienst" (Beispiel/Vorlage). Siehe README "Kategorien & Branchen-Profil". +PROFIL=praxis + # --- Kategorisierung --- # Backend: "ollama" (Default, lokal) oder "openrouter" (Cloud, nur zum Testen # ob ein staerkeres Modell besser extrahiert - Transkript verlaesst dabei den diff --git a/README.md b/README.md index 2b6ea10..4b61d13 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,54 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. | `pipe/loeschen.py` | Löscht erledigte Anrufe nach Ablauf der Aufbewahrungsfrist | | `pipe/bewertung.py` | 👍/👎-Bewertung pro Anruf (Leitstand) | | `pipe/export.py` | Exportiert bewertete Anrufe als JSONL-Trainingsdaten | -| `prompts/categorize_de.txt` | Kategorisierungs-Prompt (deutsch, mehrsprachig-tauglich) | -| `telefon/` | Ansage, FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte | +| `pipe/kategorien.py` | Lädt Kategorien-Schema des aktiven Branchen-Profils (siehe unten) | +| `profile//` | Branchen-Profil: Prompt, Kategorien, Sicherheitsnetz, Ansage | +| `telefon/` | FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte | -## Kategorien +## Kategorien & Branchen-Profil -`termin · rezept · ueberweisung · befund · verwaltung · beschwerden · notfall · -rueckruf · sonstiges` -Dringlichkeit: `niedrig · normal · hoch · notfall` +Alles, was fachlich vom Einsatzzweck abhängt, steckt in einem **Branchen-Profil** +unter `profile//` — vier Dateien, die zueinander passen müssen: + +| Datei | Inhalt | +|---|---| +| `prompt.txt` | System-Prompt für `categorize.py`, inkl. Kategorie-Beschreibungen | +| `kategorien.json` | `kategorien` (Enum fürs JSON-Schema), `kategorie_labels` (Anzeige in Leitstand/Deck/Dashboard), `sicherheitsnetz_kategorie` (Eskalationsziel) | +| `sicherheitsnetz.txt` | Ein Regex-Muster pro Zeile — deterministische Eskalation, unabhängig vom LLM | +| `ansage.txt` | Text für `telefon/ansage_bauen.sh` | + +Welches Profil aktiv ist, bestimmt `PROFIL` in der `.env` (Default `praxis`) — +ein Wechsel braucht einen Neustart von `pipe.watch`/`pipe.server`, ist aber sonst +nichts weiter als diese eine Variable. Mitgeliefert: + +- **`profile/praxis/`** (Default) — Hausarztpraxis: + `termin · rezept · ueberweisung · befund · verwaltung · beschwerden · notfall · + rueckruf · sonstiges` +- **`profile/aufzug-notdienst/`** (Beispiel/Vorlage) — 24-Stunden-Aufzug-Notdienst: + `eingeschlossen · stoerung · wartung · rueckruf · sonstiges` + +> **Wichtig bei `aufzug-notdienst`:** nur eine Vorlage, um den Mechanismus zu +> zeigen — nicht fachlich/rechtlich geprüft für echten Betrieb. Und generell +> gilt für jedes Profil mit akuter Lebensgefahr am Telefon (Person im Aufzug +> eingeschlossen, medizinischer Notfall, …): Diese Pipe nimmt nur auf und +> kategorisiert **nachträglich** — es gibt keine Live-Weiterleitung während +> des Anrufs. Das Sicherheitsnetz stuft eine Aufnahme nach der Verarbeitung +> als Notfall ein, greift aber nicht ein, während der Anrufer noch in der +> Leitung ist. Für Szenarien, in denen eine sofortige Reaktion während des +> Anrufs überlebenswichtig ist, reicht eine reine Anrufbeantworter-Architektur +> allein nicht — das ist nicht Teil dieses Projekts. + +Ein drittes Profil anlegen: `profile/praxis/` kopieren, alle vier Dateien +inhaltlich anpassen, `PROFIL=` setzen. Die Dringlichkeitsstufen +(`niedrig · normal · hoch · notfall`) sind **nicht** Teil des Profils — die +Eskalationsskala ist einsatzzweck-unabhängig und in Leitstand/Dashboard/Deck +fest verdrahtet (Farben, Sortierung, Notfall-Alarm). + +> **Hinweis:** `prompt.txt` beschreibt die Kategorien in Prosa fürs Modell, +> `kategorien.json` erzwingt sie als Enum im JSON-Schema — bewusst zwei +> Dateien, aber sie müssen inhaltlich zusammenpassen. Ändert man die +> Kategorien-Liste, muss der Prompt-Text mitgezogen werden, sonst kann das +> Modell eine im Prompt beschriebene Kategorie wählen, die das Schema ablehnt. ## Einrichtung @@ -140,7 +180,7 @@ eingehende Anrufe an, spielt die Ansage und nimmt auf. Die Aufnahme landet unter sie durch die Pipe und räumt sie nach `telefon/verarbeitet/` weg. ```bash -./telefon/ansage_bauen.sh # Ansage aus telefon/ansage.txt erzeugen +./telefon/ansage_bauen.sh # Ansage aus profile/$PROFIL/ansage.txt erzeugen ./telefon/freeswitch/einrichten.sh # Trunk + Dialplan in FreeSWITCH eintragen ./telefon/starten.sh # startet FreeSWITCH, Verarbeitung, Leitstand ``` @@ -196,7 +236,8 @@ Der SIP-Zugang steht in der `.env` (`SIP_USER`, `SIP_DOMAIN`, `SIP_PASS`). Das Einrichtungsskript trägt das Passwort in die FreeSWITCH-Konfiguration ein und setzt deren Rechte auf 600 — im Git liegen nur Vorlagen mit Platzhaltern. -Text ändern: `telefon/ansage.txt` bearbeiten, `ansage_bauen.sh` erneut +Text ändern: `profile/$PROFIL/ansage.txt` bearbeiten (siehe +[Branchen-Profil](#kategorien--branchen-profil)), `ansage_bauen.sh` erneut ausführen. Für den Praxisbetrieb muss die Ansage den Notruf 112 nennen und auf die Aufzeichnung hinweisen, bevor der Signalton kommt — beides ist bei einer Arztpraxis Pflicht, siehe „Offener Punkt" unter Datenschutz weiter unten. @@ -298,10 +339,10 @@ Gesundheitsdaten — Pflichten für den Produktivbetrieb (Stufe 2): - Löschfristen — siehe oben (`LOESCHFRIST_TAGE`). - Verarbeitung lokal (Whisper + Qwen) — kein Cloud-Dienst im Datenpfad. -> **Offener Punkt:** Die aktuelle `telefon/ansage.txt` erfüllt die ersten -> beiden Punkte noch nicht (kein Notfall-Hinweis, keine Aufnahme-Einwilligung) -> — Text anpassen und `ansage_bauen.sh` neu laufen lassen, bevor die Anlage im -> echten Praxisbetrieb ans Netz geht. +> **Offener Punkt:** Die aktuelle `profile/praxis/ansage.txt` erfüllt die +> ersten beiden Punkte noch nicht (kein Notfall-Hinweis, keine +> Aufnahme-Einwilligung) — Text anpassen und `ansage_bauen.sh` neu laufen +> lassen, bevor die Anlage im echten Praxisbetrieb ans Netz geht. > **Verschlüsselung at rest — Pflicht des Betreibers:** Die Pipe legt > Aufnahmen, Transkripte und Metadaten (`ablage/`, `telefon/`, `training/`) diff --git a/pipe/categorize.py b/pipe/categorize.py index d161060..4763db4 100644 --- a/pipe/categorize.py +++ b/pipe/categorize.py @@ -15,40 +15,33 @@ import urllib.error import urllib.request from functools import lru_cache -from . import config, modellwahl +from . import config, kategorien, modellwahl -KATEGORIEN = [ - "termin", "rezept", "ueberweisung", "befund", - "verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges", -] -DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"] +KATEGORIEN = kategorien.KATEGORIEN +DRINGLICHKEITEN = kategorien.DRINGLICHKEITEN -# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: dieselben Symptome, -# die der Prompt selbst als Notfall-Beispiele nennt (Atemnot, Brustschmerz, -# Bewusstlosigkeit, starke Blutung). Grund: getestet mit qwen3:8b, 3/3 -# identische Laeufe stuften "starke Brustschmerzen ... kriege kaum Luft" nur -# als "hoch" statt "notfall" ein - obwohl das Modell selbst "Brustschmerzen" -# und "Atemnot" als stichworte erkannte. Bei Notfall-Erkennung darf man sich -# nicht allein auf ein LLM verlassen. Eskaliert nur nach oben, nie nach unten -# - ein Fehlalarm kostet einen Blick, ein uebersehener Notfall kann einen -# Patienten kosten (siehe auch die "im Zweifel hoeher"-Regel im Prompt). -_NOTFALL_MUSTER = [re.compile(p, re.IGNORECASE) for p in ( - r"atemnot", - r"(kriege|bekomme|krieg)\w*\s+(kaum|keine|schwer)\s+luft", - r"kann\s+(kaum|nicht)\s+(mehr\s+)?atmen", - r"brust\w*schmerz", - r"schmerzen?\s+in\s+der\s+brust", - r"bewusstlos", - r"ohnm[aä]chtig", - r"nicht\s+ansprechbar", - r"blutet\s+(stark|heftig|sehr)", - r"starke?\s+blutung", -)] + +@lru_cache(maxsize=1) +def _sicherheitsnetz_muster() -> list[re.Pattern]: + """Lädt die Eskalations-Regex-Muster aus profile//sicherheitsnetz.txt. + + Ein Muster pro Zeile, Kommentare ("#") und Leerzeilen werden übersprungen. + Deterministisch, unabhängig vom LLM - siehe Rationale in der Profil-Datei + selbst. Eskaliert nur nach oben, nie nach unten. + """ + if not config.SICHERHEITSNETZ_DATEI.is_file(): + return [] + return [ + re.compile(zeile, re.IGNORECASE) + for zeile in (z.strip() for z in config.SICHERHEITSNETZ_DATEI.read_text(encoding="utf-8").splitlines()) + if zeile and not zeile.startswith("#") + ] def _notfall_sicherheitsnetz(transkript: str, d: dict) -> dict: - if d.get("dringlichkeit") != "notfall" and any(m.search(transkript) for m in _NOTFALL_MUSTER): - d["kategorie"] = "notfall" + ziel = kategorien.SICHERHEITSNETZ_KATEGORIE + if d.get("kategorie") != ziel and any(m.search(transkript) for m in _sicherheitsnetz_muster()): + d["kategorie"] = ziel d["dringlichkeit"] = "notfall" return d @@ -244,11 +237,11 @@ def _bereinige(d: dict) -> dict: d["kategorie"] = "sonstiges" if d.get("dringlichkeit") not in DRINGLICHKEITEN: d["dringlichkeit"] = "normal" - # Das Modell vergibt gelegentlich die Kategorie "notfall" und stuft die - # Dringlichkeit trotzdem nur auf "hoch" ein. Das ist widersprüchlich und - # führt zu einer harmloseren Farbe auf dem Board, als der Anruf verdient. - # In der Praxis eskaliert man im Zweifel, statt abzuschwächen. - if d["kategorie"] == "notfall": + # Das Modell vergibt gelegentlich die Sicherheitsnetz-Kategorie (z. B. + # "notfall") und stuft die Dringlichkeit trotzdem nur auf "hoch" ein. Das + # ist widersprüchlich und führt zu einer harmloseren Farbe auf dem Board, + # als der Anruf verdient. Im Zweifel eskaliert man, statt abzuschwächen. + if d["kategorie"] == kategorien.SICHERHEITSNETZ_KATEGORIE: d["dringlichkeit"] = "notfall" d.setdefault("anrufer_name", None) d.setdefault("rueckrufnummer", None) diff --git a/pipe/config.py b/pipe/config.py index fb46e4b..7c04eb1 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -83,9 +83,21 @@ OPENROUTER_KEY = os.environ.get("OPENROUTER_KEY", "") # hinterlegten (siehe pipe/modellwahl.py) - leer lassen wenn nicht gebraucht. OPENROUTER_MODELL = os.environ.get("OPENROUTER_MODELL", "") -PROMPT_DATEI = WURZEL / "prompts" / os.environ.get( - "PROMPT_DATEI", "categorize_de.txt" -) +# --- Branchen-Profil ----------------------------------------------------- +# Buendelt alles, was fachlich vom Einsatzzweck abhaengt (Kategorien, Prompt, +# Notfall-Sicherheitsnetz, Ansage) an einer Stelle - siehe profile//. +# So bleiben diese vier Teile beim Wechsel des Einsatzzwecks (z.B. Praxis vs. +# 24h-Aufzug-Notdienst) automatisch zueinander konsistent, statt dass man eine +# Datei beim Umstellen vergisst. Neues Profil anlegen: profile// Ordner +# mit prompt.txt, kategorien.json, sicherheitsnetz.txt, ansage.txt anlegen +# (profile/praxis/ als Vorlage nehmen) und hier PROFIL= setzen. +PROFIL = os.environ.get("PROFIL", "praxis").strip() or "praxis" +PROFIL_ORDNER = WURZEL / "profile" / PROFIL + +PROMPT_DATEI = PROFIL_ORDNER / "prompt.txt" +KATEGORIEN_DATEI = PROFIL_ORDNER / "kategorien.json" +SICHERHEITSNETZ_DATEI = PROFIL_ORDNER / "sicherheitsnetz.txt" +ANSAGE_DATEI = PROFIL_ORDNER / "ansage.txt" # --- Ablage ------------------------------------------------------------------ ABLAGE_LOKAL = Path(os.environ.get("ABLAGE_LOKAL", str(WURZEL / "ablage"))) diff --git a/pipe/dashboard.py b/pipe/dashboard.py index bb7a370..06319f0 100644 --- a/pipe/dashboard.py +++ b/pipe/dashboard.py @@ -11,15 +11,11 @@ import json import sys from pathlib import Path -from . import config +from . import config, kategorien RANG = {"notfall": 0, "hoch": 1, "normal": 2, "niedrig": 3} FARBE = {"notfall": "#E9322D", "hoch": "#E0A339", "normal": "#C9A227", "niedrig": "#31CC7C"} -KAT_LABEL = { - "termin": "Termin", "rezept": "Rezept", "ueberweisung": "Überweisung", - "befund": "Befund", "verwaltung": "Verwaltung", "beschwerden": "Beschwerden", "notfall": "Notfall", - "rueckruf": "Rückruf", "sonstiges": "Sonstiges", -} +KAT_LABEL = kategorien.KATEGORIE_LABEL def _anrufe() -> list[dict]: @@ -65,7 +61,7 @@ def baue(ausgabe: Path) -> Path: karten = "\n".join(_karte(d) for d in anrufe) doc = f""" -Anruf-Übersicht — Praxis Musterhausen +Anruf-Übersicht
-

Anruf-Übersicht

+

Anruf-Übersicht · {html.escape(kategorien.PROFIL_NAME)}

{len(anrufe)} Anrufe · {zusammenfassung or '—'} · lokal erzeugt aus der Ablage

{karten}
""" diff --git a/pipe/kategorien.py b/pipe/kategorien.py index 843c978..ec8de04 100644 --- a/pipe/kategorien.py +++ b/pipe/kategorien.py @@ -27,6 +27,9 @@ def _daten() -> dict: KATEGORIEN = _daten()["kategorien"] KATEGORIE_LABEL = _daten().get("kategorie_labels", {}) +# Anzeigename des aktiven Profils (Leitstand-Header, Dashboard) - Fallback auf +# den rohen Ordnernamen, falls ein Profil das Feld nicht setzt. +PROFIL_NAME = _daten().get("profil_name", config.PROFIL) # Kategorie, auf die das deterministische Sicherheitsnetz eskaliert (siehe # categorize._notfall_sicherheitsnetz) - bei "praxis" ist das "notfall", bei # anderen Profilen ggf. ein anderer Name (z. B. "eingeschlossen"). diff --git a/pipe/server.py b/pipe/server.py index bc84733..ae3c353 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse import base64 import hmac +import html import json import re import sys @@ -404,7 +405,7 @@ class Handler(BaseHTTPRequestHandler): SEITE = r""" -Logpy:AgentOne — Anruf-Leitstand +Logpy:AgentOne — Anruf-Leitstand · __PROFIL_NAME__
-

Logpy:AgentOne

Anruf-Leitstand
+

Logpy:AgentOne

Anruf-Leitstand · __PROFIL_NAME__
@@ -999,7 +1000,8 @@ def _seite() -> str: Kategorien selbst) - deshalb einmalig gecacht statt bei jeder Anfrage neu zusammengesetzt. """ - return SEITE.replace("__KAT_JSON__", json.dumps(kategorien.KATEGORIE_LABEL, ensure_ascii=False)) + seite = SEITE.replace("__KAT_JSON__", json.dumps(kategorien.KATEGORIE_LABEL, ensure_ascii=False)) + return seite.replace("__PROFIL_NAME__", html.escape(kategorien.PROFIL_NAME)) def main(argv: list[str] | None = None) -> int: diff --git a/profile/aufzug-notdienst/kategorien.json b/profile/aufzug-notdienst/kategorien.json index 8b8571e..4aa1d6c 100644 --- a/profile/aufzug-notdienst/kategorien.json +++ b/profile/aufzug-notdienst/kategorien.json @@ -1,4 +1,5 @@ { + "profil_name": "Aufzug-Notdienst", "kategorien": ["eingeschlossen", "stoerung", "wartung", "rueckruf", "sonstiges"], "kategorie_labels": { "eingeschlossen": "Person eingeschlossen", diff --git a/profile/praxis/kategorien.json b/profile/praxis/kategorien.json index 0eeeb56..887f6e0 100644 --- a/profile/praxis/kategorien.json +++ b/profile/praxis/kategorien.json @@ -1,4 +1,5 @@ { + "profil_name": "Hausarztpraxis", "kategorien": [ "termin", "rezept", "ueberweisung", "befund", "verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges" From b1cb3471a5458a137142894806a3f39623e9241d Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:32:10 +0200 Subject: [PATCH 16/26] feat: Verlauf zeigt jetzt, wer eine Karte verschoben hat _angemeldet() merkt sich jetzt den eingeloggten Nutzernamen (self._nutzer) - bei mehreren Leitstand-Konten (LEITSTAND_ZUGAENGE) war bisher nicht nachvollziehbar, wer eine Karte zwischen den Stapeln verschoben hat. Der Verlauf-Eintrag zeigt "(von )" in derselben Zeile, "nutzer" steht zusaetzlich als eigenes Feld im JSONL (Verlauf + Audit-Log). Getestet: Verschieben per Testanruf + notdienst-Login, Eintrag im Verlauf verifiziert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/server.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pipe/server.py b/pipe/server.py index ae3c353..1658a30 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -187,6 +187,7 @@ class Handler(BaseHTTPRequestHandler): """ self._zusatz_header: list[tuple[str, str]] = [] self._via_token = False # True nur bei Zugang per Testzugangs-Token, nicht Passwort + self._nutzer: str | None = None # wer angemeldet ist - fuer den Verlauf (wer hat was verschoben) konten = config.leitstand_konten() if not konten: return True @@ -197,10 +198,12 @@ class Handler(BaseHTTPRequestHandler): self._zusatz_header.append( ("Set-Cookie", f"zugang={token}; Max-Age={rest_s}; Path=/; HttpOnly; Secure; SameSite=Lax")) self._via_token = True + self._nutzer = "Testzugang" return True cookie_wert = SimpleCookie(self.headers.get("Cookie", "")).get("zugang") if cookie_wert and testzugang.gueltig(cookie_wert.value): self._via_token = True + self._nutzer = "Testzugang" return True kopf = self.headers.get("Authorization", "") @@ -212,7 +215,10 @@ class Handler(BaseHTTPRequestHandler): return False # compare_digest: Vergleichsdauer verrät nichts über das Passwort. erwartet = konten.get(nutzer) - return erwartet is not None and hmac.compare_digest(wort, erwartet) + if erwartet is None or not hmac.compare_digest(wort, erwartet): + return False + self._nutzer = nutzer + return True def _anmeldung_verlangen(self) -> None: self.send_response(401) @@ -341,7 +347,8 @@ class Handler(BaseHTTPRequestHandler): if not stapel.setze(ordner, ziel): self._json({"ok": False, "fehler": f"unbekannter Stapel: {ziel}"}) return - protokoll.schreibe("stapel", f"{ordner.name} → {ziel}") + zusatz = f" (von {self._nutzer})" if self._nutzer else "" + protokoll.schreibe("stapel", f"{ordner.name} → {ziel}{zusatz}", nutzer=self._nutzer) self._json({"ok": True}) def _audio(self, rest: str) -> None: From 184fb375937a4a322fae79340e5f827568a553ee Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:39:26 +0200 Subject: [PATCH 17/26] feat: eingeloggter Nutzer im Leitstand-Header sichtbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mit mehreren Leitstand-Konten (praxis/notdienst/admin) war im UI selbst nicht erkennbar, als wer man gerade angemeldet ist. /api/status liefert jetzt "nutzer" (aus self._nutzer, siehe voriger Commit), das Frontend zeigt ihn im Header neben dem Stand-Zeitstempel ("👤 notdienst"). Nebenbei behoben: dienste.status() gibt ein gecachtes, ueber Requests geteiltes dict zurueck - der Handler mutierte es bisher direkt (testzugang-Feld). Bei gleichzeitigen Anfragen verschiedener Nutzer haette das den Nutzernamen der einen Anfrage in die Antwort einer anderen durchsickern lassen koennen. Jetzt eine flache Kopie vor dem Mutieren. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/server.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pipe/server.py b/pipe/server.py index 1658a30..60cccd7 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -248,10 +248,15 @@ class Handler(BaseHTTPRequestHandler): if pfad == "/": self._sende(_seite().encode("utf-8"), "text/html; charset=utf-8") elif pfad == "/api/status": - daten = dienste.status() + # Kopie, nicht das gecachte dienste.status()-Objekt direkt + # mutieren - der ThreadingHTTPServer bedient mehrere Nutzer + # gleichzeitig, sonst koennte der Nutzername der einen Anfrage in + # der Antwort einer anderen landen. + daten = dict(dienste.status()) # Testzugang (per "agent-one test"-Link) -> Sicher-Modus im # Frontend erzwingen, siehe sicherSetzen() im JS. daten["testzugang"] = getattr(self, "_via_token", False) + daten["nutzer"] = self._nutzer self._json(daten) elif pfad == "/api/anrufe": liste = anrufe() @@ -429,6 +434,7 @@ h1{font-size:19px;margin:0;line-height:1.25} .untertitel{font-size:12px;color:var(--muted);font-weight:400} .logo{width:34px;height:34px;border-radius:50%;flex:none;box-shadow:0 1px 3px rgba(0,0,0,.15)} .stand{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums;margin-left:auto} +.nutzer-anzeige{color:var(--muted);font-size:13px;margin-left:10px} .modell-select{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px; padding:6px 10px;font-size:13px;font-family:inherit;cursor:pointer;max-width:280px} .verbindung-banner{display:none;align-items:center;gap:9px;background:var(--unklar);color:#1a1500; @@ -563,6 +569,7 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight: +
Dienste
@@ -796,6 +803,7 @@ function zeichneStatus(s){ sicherSetzen(true); } document.getElementById('stand').textContent='Stand '+s.stand; + document.getElementById('nutzerAnzeige').textContent=s.nutzer?('👤 '+s.nutzer):''; document.getElementById('dienste').innerHTML=s.dienste.map(d=> `
${esc(d.name)}${esc(d.text)}
`).join(''); From 37a5a78b15c47930e5c065feb70dc65fbdeb200c Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:41:51 +0200 Subject: [PATCH 18/26] feat: Abmelden-Button im Leitstand HTTP Basic Auth kennt keine Server-Session zum Beenden - der Browser cached die Zugangsdaten selbst. Der neue Button loescht das Testzugang-Cookie und schickt einen Request mit falschen Zugangsdaten (per XHR mit explizitem user/password), was den Basic-Auth-Cache der gaengigen Browser ueberschreibt; ein Reload danach zeigt wieder den Anmelde-Dialog. Nur sichtbar, wenn ueberhaupt ein Zugangsschutz aktiv ist (s.nutzer gesetzt). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/server.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pipe/server.py b/pipe/server.py index 60cccd7..dd3792b 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -469,6 +469,9 @@ padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit} .export-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px; padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit;margin-left:8px} .export-btn:hover{border-color:var(--muted)} +.logout-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px; +padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit;margin-left:8px;display:none} +.logout-btn:hover{border-color:var(--muted)} .melde-btn{font-size:13px;background:var(--bg);border:1px solid var(--rand);border-radius:7px; padding:2px 6px;cursor:pointer;line-height:1.4;opacity:.5;flex:none} .melde-btn:hover{opacity:1;border-color:var(--muted)} @@ -570,6 +573,7 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight: +
Dienste
@@ -804,6 +808,7 @@ function zeichneStatus(s){ } document.getElementById('stand').textContent='Stand '+s.stand; document.getElementById('nutzerAnzeige').textContent=s.nutzer?('👤 '+s.nutzer):''; + document.getElementById('logoutBtn').style.display=s.nutzer?'':'none'; document.getElementById('dienste').innerHTML=s.dienste.map(d=> `
${esc(d.name)}${esc(d.text)}
`).join(''); @@ -957,6 +962,21 @@ sicherBtn.onclick=()=>{ sicherSetzen(!document.body.classList.contains('sicher')); }; +// Abmelden: es gibt keine Server-Session, die man loeschen koennte (HTTP +// Basic Auth) - der Browser cached die Zugangsdaten selbst. Zwei Schritte: +// 1) Testzugang-Cookie loeschen (falls darueber angemeldet). 2) Einen +// Request mit falschen Zugangsdaten schicken - Browser ueberschreiben damit +// nachweislich ihren Credential-Cache fuer diesen Ursprung, ein Reload +// danach zeigt wieder den Anmelde-Dialog. Funktioniert in den gaengigen +// Browsern, ist aber kein Ersatz fuer "Fenster schliessen" bei geteilten Rechnern. +document.getElementById('logoutBtn').onclick=()=>{ + document.cookie='zugang=; Max-Age=0; Path=/'; + const xhr=new XMLHttpRequest(); + xhr.open('GET','/',true,'logout','logout'); + xhr.onloadend=()=>location.reload(); + xhr.send(); +}; + // Kategorisierungs-Modell (Dropdown oben rechts) - Wechsel greift sofort // beim naechsten Anruf, kein Neustart des Watchers noetig (siehe // pipe/modellwahl.py, geteilte Zustandsdatei). From be11baf52b157ce69f71dd8bb34a1e06fd3a19bd Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:59:24 +0200 Subject: [PATCH 19/26] feat: ?logoff in der URL loest die Abmeldung zusaetzlich zum Button aus Der Abmelden-Button blendet sich erst nach dem ersten erfolgreichen Status-Abruf ein - bei zugestopftem/schmalem Header oder einer alten, nicht neu geladenen Seite war er dadurch schwer zu finden. ".../?logoff" an die Leitstand-URL angehaengt loest denselben Ablauf jetzt garantiert aus, unabhaengig vom Button. Query-String wird danach aus der URL entfernt, damit ein Reload nicht erneut abmeldet. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pipe/server.py b/pipe/server.py index dd3792b..80f1e62 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -969,13 +969,18 @@ sicherBtn.onclick=()=>{ // nachweislich ihren Credential-Cache fuer diesen Ursprung, ein Reload // danach zeigt wieder den Anmelde-Dialog. Funktioniert in den gaengigen // Browsern, ist aber kein Ersatz fuer "Fenster schliessen" bei geteilten Rechnern. -document.getElementById('logoutBtn').onclick=()=>{ +// Zwei Ausloeser: der Button (falls sichtbar) UND ?logoff in der URL - Zweiteres +// funktioniert immer, unabhaengig davon, ob der Button im Header gerade zu +// sehen ist (z.B. bei zugestopftem Header auf schmalen Bildschirmen). +function abmelden(){ document.cookie='zugang=; Max-Age=0; Path=/'; const xhr=new XMLHttpRequest(); xhr.open('GET','/',true,'logout','logout'); - xhr.onloadend=()=>location.reload(); + xhr.onloadend=()=>{location.href=location.pathname}; xhr.send(); -}; +} +document.getElementById('logoutBtn').onclick=abmelden; +if(new URLSearchParams(location.search).has('logoff'))abmelden(); // Kategorisierungs-Modell (Dropdown oben rechts) - Wechsel greift sofort // beim naechsten Anruf, kein Neustart des Watchers noetig (siehe From ab2bd949be3d2a4f48d81c91d79468b9e0755f21 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:07:31 +0200 Subject: [PATCH 20/26] feat: Log-Rotation fuer Rohlogs + Aufraeumen alter Verlauf-Archivkopien Audit ergab: "Tag archivieren" sichert/leert nur verlauf.log. Die rohen stdout/stderr-Logs (telefon/autostart.log vom launchd-Watchdog net.dillenberg.fonagent, alle 5 Min; watcher.log/leitstand.log/monitor.log) wachsen unbegrenzt - autostart.log stand bei 523 KB/16595 Zeilen ohne jede Bereinigung. audit.log ist bewusst permanent (Compliance, unveraendert). starten.sh kuerzt diese vier Dateien jetzt ab 2 MB auf die letzten 2000 Zeilen (in-place, gleiches Inode) - laeuft ohnehin alle 5 Min per Watchdog. Redirects von > auf >> umgestellt (O_APPEND), damit die In-Place-Kuerzung fuer die noch laufenden Prozesse sicher ist (kein Datenverlust durch gecachte Schreib-Offsets). pipe.protokoll.raeume_archiv_auf() loescht verlauf-archiv/*.log aelter als 90 Tage - lief bisher unbegrenzt an. Eingehaengt in pipe.watch's ohnehin taeglichen Check (bisher nur fuer LOESCHFRIST_TAGE), jetzt unabhaengig davon. Getestet: Kuerz-Logik (3,8 MB -> 2000 Zeilen) und raeume_archiv_auf() (gezielt gealterte Testdatei geloescht, Rest unangetastet) isoliert verifiziert, echte Dateien nicht angefasst. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/protokoll.py | 24 ++++++++++++++++++++++++ pipe/watch.py | 16 ++++++++++++---- telefon/starten.sh | 22 ++++++++++++++++++---- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/pipe/protokoll.py b/pipe/protokoll.py index e04ddfe..cf1450d 100644 --- a/pipe/protokoll.py +++ b/pipe/protokoll.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import shutil +import time from datetime import datetime from pathlib import Path @@ -79,3 +80,26 @@ def archiviere_und_leere() -> Path | None: return ziel except Exception: return None + + +ARCHIV_FRIST_TAGE = 90 + + +def raeume_archiv_auf(tage: int = ARCHIV_FRIST_TAGE) -> int: + """Löscht Verlauf-Archivkopien (nicht audit.log!) älter als `tage` Tage. + + Ein neues verlauf_*.log entsteht bei jedem "Tag archivieren" - ohne + Aufräumen sammeln sich diese sonst unbegrenzt an. Wirft nie, gibt die + Anzahl gelöschter Dateien zurück.""" + if not ARCHIV_ORDNER.is_dir(): + return 0 + grenze = time.time() - tage * 86400 + geloescht = 0 + for datei in ARCHIV_ORDNER.glob("verlauf_*.log"): + try: + if datei.stat().st_mtime < grenze: + datei.unlink() + geloescht += 1 + except Exception: + continue + return geloescht diff --git a/pipe/watch.py b/pipe/watch.py index 49bca05..7fac5c0 100644 --- a/pipe/watch.py +++ b/pipe/watch.py @@ -152,10 +152,18 @@ def main(argv: list[str] | None = None) -> int: try: while True: durchlauf(eingang) - if config.LOESCHFRIST_TAGE and time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S: - anzahl = loeschen.laeuft() - if anzahl: - print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.") + if time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S: + if config.LOESCHFRIST_TAGE: + anzahl = loeschen.laeuft() + if anzahl: + print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.") + # Verlauf-Archivkopien (pipe.protokoll, "Tag archivieren") + # sammeln sich sonst unbegrenzt an - unabhaengig von + # LOESCHFRIST_TAGE, das nur Anrufe betrifft, nicht Log-Kopien. + archiv_geloescht = protokoll.raeume_archiv_auf() + if archiv_geloescht: + print(f"🗑 {archiv_geloescht} alte Verlauf-Archivkopie(n) geloescht " + f"(> {protokoll.ARCHIV_FRIST_TAGE} Tage).") letzter_loeschlauf = time.monotonic() time.sleep(TAKT_S) except KeyboardInterrupt: diff --git a/telefon/starten.sh b/telefon/starten.sh index 17e3141..caef1c8 100755 --- a/telefon/starten.sh +++ b/telefon/starten.sh @@ -18,6 +18,20 @@ if [ -f .env ]; then done < .env fi +# Rohe Prozess-Logs (stdout/stderr von watcher/leitstand/monitor + die +# eigene Ausgabe hier, wenn per launchd-Watchdog alle 5 Min aufgerufen) +# wachsen sonst unbegrenzt - anders als verlauf.log (protokoll.kuerze(), nach +# jedem Anruf) und audit.log (bewusst permanent, siehe pipe/audit.py). Ab +# 2 MB auf die letzten 2000 Zeilen kuerzen, in-place (gleiches Inode) - sicher +# fuer >>-Redirects (O_APPEND springt bei jedem Schreiben ans Dateiende, +# unabhaengig vom vorherigen Offset). +LOG_GRENZE_BYTES=2000000 +for log in telefon/autostart.log telefon/watcher.log telefon/leitstand.log telefon/monitor.log; do + if [ -f "$log" ] && [ "$(wc -c < "$log" 2>/dev/null || echo 0)" -gt "$LOG_GRENZE_BYTES" ]; then + tail -n 2000 "$log" > "$log.tmp" 2>/dev/null && cat "$log.tmp" > "$log" && rm -f "$log.tmp" + fi +done + fehler=0 melde() { printf ' %-38s %s\n' "$1" "$2"; } @@ -99,20 +113,20 @@ if [ "${status:-}" = "UP" ]; then else # nice 10: Transkription und Kategorisierung dürfen warten, ein laufendes # Telefonat nicht. Auf 16 GB RAM konkurrieren beide sonst um die Maschine. - nohup nice -n 10 python3 -u -m pipe.watch > telefon/watcher.log 2>&1 & + nohup nice -n 10 python3 -u -m pipe.watch >> telefon/watcher.log 2>&1 & echo "Verarbeitung gestartet (Log: telefon/watcher.log)" fi if pgrep -f "[p]ipe.server" >/dev/null; then echo "Leitstand laeuft bereits." else - nohup python3 -u -m pipe.server > telefon/leitstand.log 2>&1 & + nohup python3 -u -m pipe.server >> telefon/leitstand.log 2>&1 & sleep 2 - head -1 telefon/leitstand.log + tail -1 telefon/leitstand.log fi if pgrep -f "[p]ipe.monitor" >/dev/null; then echo "Stoerungswache laeuft bereits." else - nohup python3 -u -m pipe.monitor > telefon/monitor.log 2>&1 & + nohup python3 -u -m pipe.monitor >> telefon/monitor.log 2>&1 & echo "Stoerungswache gestartet (Log: telefon/monitor.log)" fi sleep 2 From 5c9f891c7e45ef25b903c9d2a12e177c15bc424e Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:14:21 +0200 Subject: [PATCH 21/26] perf: /api/anrufe und /api/verlauf cachen, Dringlichkeit-Farben zentralisiert Audit auf Optimierungspotenzial ergab: - /api/anrufe las bei jeder Anfrage ALLE meta.json unter ablage/ neu (auch bereits archivierte, erst danach rausgefiltert) - waechst mit der Gesamtzahl je empfangener Anrufe, jetzt von bis zu 3 Logins alle 3s parallel gepollt. - /api/verlauf las bei jeder Anfrage bis zu 300 KB aus dem FreeSWITCH-Log und scannte 4000 Zeilen per Regex, ebenfalls ungecacht. Beide bekommen jetzt einen kurzen 2s-Cache (gleiches Muster wie dienste.status()). Maskierung bei Testzugang arbeitet jetzt auf einer Kopie (dict(a) je Eintrag) statt die gecachten Objekte in-place zu veraendern - sonst haetten maskierte Rufnummern in die naechste, nicht maskierte Antwort durchsickern koennen. Dringlichkeit-Farben (rot/orange/gruen/grau) und der Sortierrang standen in drei Dateien dupliziert (dashboard.py, deck.py, server.py-JS) - bei der letzten Farbaenderung mussten alle drei von Hand synchron gehalten werden. Jetzt eine Quelle in pipe/kategorien.py (DRINGLICHKEIT_FARBE, DRINGLICHKEIT_RANG - Rang aus DRINGLICHKEITEN abgeleitet statt separat gepflegt), server.py injiziert die Farben serverseitig wie schon die Kategorie-Labels. Getestet: RANG/FARBE identisch zum alten Stand, /api/anrufe und /api/verlauf liefern bei zwei Anfragen innerhalb 2s dasselbe (gecachte) Ergebnis, Maskierung veraendert nachweislich nur die Kopie. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/dashboard.py | 4 ++-- pipe/deck.py | 9 ++------- pipe/kategorien.py | 18 +++++++++++++++--- pipe/server.py | 46 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/pipe/dashboard.py b/pipe/dashboard.py index 04b9956..f13dd4e 100644 --- a/pipe/dashboard.py +++ b/pipe/dashboard.py @@ -13,8 +13,8 @@ from pathlib import Path from . import config, kategorien -RANG = {"notfall": 0, "hoch": 1, "normal": 2, "niedrig": 3} -FARBE = {"notfall": "#E9322D", "hoch": "#E0A339", "normal": "#31CC7C", "niedrig": "#8A93A3"} +RANG = kategorien.DRINGLICHKEIT_RANG +FARBE = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()} KAT_LABEL = kategorien.KATEGORIE_LABEL diff --git a/pipe/deck.py b/pipe/deck.py index 12d5d6f..c1b117e 100644 --- a/pipe/deck.py +++ b/pipe/deck.py @@ -22,13 +22,8 @@ from . import config, kategorien _API = "/index.php/apps/deck/api/v1.1" -# Farben (6-stelliges Hex ohne #) je Dringlichkeit. -DRINGLICHKEIT_FARBE = { - "niedrig": "8A93A3", # grau - "normal": "31CC7C", # grün - "hoch": "E0A339", # orange - "notfall": "E9322D", # rot -} +# Farben je Dringlichkeit - siehe pipe/kategorien.py. +DRINGLICHKEIT_FARBE = kategorien.DRINGLICHKEIT_FARBE # Lesbare Namen der Kategorien für den Kartentitel - der Stapel sagt jetzt, # wie weit die Bearbeitung ist, nicht mehr worum es geht. KATEGORIE_LABEL = kategorien.KATEGORIE_LABEL diff --git a/pipe/kategorien.py b/pipe/kategorien.py index ec8de04..4365a04 100644 --- a/pipe/kategorien.py +++ b/pipe/kategorien.py @@ -6,9 +6,10 @@ Label) dieselbe Quelle nutzen - ein neues Profil ändert nur die JSON-Datei, kein Python-Code. Dringlichkeitsstufen sind bewusst NICHT Teil des Profils: die vierstufige -Eskalationsskala (niedrig/normal/hoch/notfall) ist einsatzzweck-unabhängig -und steckt fest in Farben/Reihenfolge in dashboard.py, deck.py und der -Leitstand-Oberfläche - nur die Kategorien selbst unterscheiden sich fachlich. +Eskalationsskala (niedrig/normal/hoch/notfall) ist einsatzzweck-unabhängig. +Farbe und Sortierrang je Stufe stehen deshalb ebenfalls hier - eine Quelle +für dashboard.py, deck.py und den Leitstand (server.py), statt an drei +Stellen von Hand synchron zu halten. """ from __future__ import annotations @@ -18,6 +19,17 @@ from functools import lru_cache from . import config DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"] +# Farben (6-stelliges Hex ohne "#" - so will es Nextcloud Deck; dashboard.py +# und server.py stellen sich das "#" selbst voran). +DRINGLICHKEIT_FARBE = { + "niedrig": "8A93A3", # grau - unwichtig + "normal": "31CC7C", # grün + "hoch": "E0A339", # orange + "notfall": "E9322D", # rot +} +# Sortierrang (0 = dringlichst zuerst) - aus DRINGLICHKEITEN abgeleitet statt +# separat gepflegt. +DRINGLICHKEIT_RANG = {d: i for i, d in enumerate(reversed(DRINGLICHKEITEN))} @lru_cache(maxsize=1) diff --git a/pipe/server.py b/pipe/server.py index 80f1e62..e927781 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -26,7 +26,26 @@ from urllib.parse import parse_qs, unquote, urlparse from . import bewertung, config, dashboard, dienste, export, kategorien, modellwahl, protokoll, stapel, testzugang +# Anrufe() liest alle meta.json unter ablage/ (auch bereits archivierte, die +# erst danach rausgefiltert werden) - waechst mit der Gesamtzahl je +# empfangener Anrufe. Mehrere angemeldete Nutzer pollen das alle 3s parallel +# (siehe takt() im Frontend) - kurz zwischenspeichern, damit das nicht mit +# der Nutzerzahl skaliert. +_ANRUFE_CACHE: dict[str, tuple[float, list[dict]]] = {} +ANRUFE_CACHE_S = 2.0 + + def anrufe() -> list[dict]: + jetzt = time.time() + gepuffert = _ANRUFE_CACHE.get("anrufe") + if gepuffert and jetzt - gepuffert[0] < ANRUFE_CACHE_S: + return gepuffert[1] + liste = _anrufe_berechnen() + _ANRUFE_CACHE["anrufe"] = (jetzt, liste) + return liste + + +def _anrufe_berechnen() -> list[dict]: liste = [] for d in dashboard._anrufe(): e = d["auswertung"] @@ -138,11 +157,24 @@ def _telefon_ereignisse(anzahl: int = 60) -> list[dict]: return ereignisse[-anzahl:] +# _telefon_ereignisse() liest bis zu 300 KB aus dem FreeSWITCH-Log und scannt +# bis zu 4000 Zeilen per Regex - bei mehreren Nutzern, die alle 3s pollen, +# unnoetig oft. Gleicher kurzer Cache wie bei anrufe(). +_VERLAUF_CACHE: dict[str, tuple[float, list[dict]]] = {} +VERLAUF_CACHE_S = 2.0 + + def verlauf(anzahl: int = 150) -> list[dict]: """Pipe-Protokoll und Telefonie-Ereignisse, zeitlich zusammengeführt.""" + jetzt = time.time() + gepuffert = _VERLAUF_CACHE.get("verlauf") + if gepuffert and jetzt - gepuffert[0] < VERLAUF_CACHE_S: + return gepuffert[1] alle = protokoll.lies(anzahl) + _telefon_ereignisse() alle.sort(key=lambda e: e.get("zeit") or "") - return alle[-anzahl:] + ergebnis = alle[-anzahl:] + _VERLAUF_CACHE["verlauf"] = (jetzt, ergebnis) + return ergebnis # Bei Testzugang (kein Passwort, oeffentlicher Link) werden Rufnummern nicht @@ -259,14 +291,18 @@ class Handler(BaseHTTPRequestHandler): daten["nutzer"] = self._nutzer self._json(daten) elif pfad == "/api/anrufe": + # anrufe() ist gecacht (mehrere Nutzer pollen alle 3s parallel) - + # bei Testzugang auf einer Kopie maskieren, sonst wuerden die + # ersetzten Rufnummern die naechste (nicht maskierte) Antwort + # verseuchen. liste = anrufe() if getattr(self, "_via_token", False): - liste = _maskiere_anrufe(liste) + liste = _maskiere_anrufe([dict(a) for a in liste]) self._json({"stapel": stapel.erlaubt(), "anrufe": liste}) elif pfad == "/api/verlauf": eintraege = verlauf(150) if getattr(self, "_via_token", False): - eintraege = _maskiere_verlauf(eintraege) + eintraege = _maskiere_verlauf([dict(e) for e in eintraege]) self._json(eintraege) elif pfad == "/api/modell": self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.auswahl()}) @@ -621,7 +657,7 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight: