diff --git a/.env.example b/.env.example index 594c269..6a34369 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,6 @@ 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 @@ -51,31 +45,12 @@ 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 SIP_PASS= SIP_PROXY=voice01.sip.plusnet.de -# --- Durchwahl 3: eigenes Endgeraet vor dem Anrufbeantworter klingeln lassen --- -# Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim -# Anrufbeantworter. Manche Provider (z.B. Fonial/Plusnet) klingeln bei einer -# Durchwahl PARALLEL an mehreren registrierten Zielen - ohne Wartezeit -# gewinnt AgentOne das Rennen immer, weil es sofort abnimmt (answer() ist die -# erste Aktion im Dialplan). Die DDI kommt bei Plusnet nicht im -# destination_number an (der ist bei diesem Trunk unbrauchbar), sondern im -# Header X-ORIGINAL-DDI-URI - siehe telefon/freeswitch/dw3_warten.xml.tpl. -# Nur Ziffern, kein "+". -DW3_DDI= - # --- Ansage --- # piper = lokal, natuerlich, Modelle unter ~/piper-voices # say = lokal, macOS-Bordmittel (Rueckfall wenn Piper fehlt) @@ -110,13 +85,6 @@ LEITSTAND_HOST=127.0.0.1 LEITSTAND_PORT=8088 LEITSTAND_USER=praxis LEITSTAND_PASS= -# Weitere Konten zusaetzlich zum obigen - kommagetrennt "nutzer:passwort", -# z.B. fuer ein zweites Team/einen zweiten Einsatzzweck neben "praxis". -LEITSTAND_ZUGAENGE= - -# 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 @@ -128,9 +96,3 @@ FS_ETC=/opt/homebrew/etc/freeswitch # Log von FreeSWITCH; der Leitstand liest daraus die Anruf-Ereignisse. # Apple Silicon: /opt/homebrew/... , Intel-Mac: /usr/local/... #FREESWITCH_LOG=/opt/homebrew/var/log/freeswitch/freeswitch.log - -# Pipecat-Testleitung (dialog/pipecat_bootstrap.py, Nebenstelle 7501) - -# WebSocket-Ziel fuer mod_audio_stream. token= muss ASTRA_TELEFON_SECRET -# auf der Astra-Maschine entsprechen. -ASTRA_TELEFON_WS_URL=wss://astra-host.example.ts.net/telefon/inbound?token=changeme -PIPECAT_BOOTSTRAP_PORT=8095 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d1f464..99a9690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,44 +1,5 @@ # Changelog -## v1.1.0 — 2026-08-30 - -Branchen-Profile machen das Projekt für andere Einsatzzwecke als die -Hausarztpraxis konfigurierbar (z. B. 24h-Notdienst), dazu mehrere -Leitstand-Härtungen und Aufräumarbeiten. - -### Branchen-Profile (neu) -- Projekt ist jetzt per `PROFIL`-Wahl für andere Einsatzzwecke konfigurierbar - (Praxis, Aufzug-Notdienst, …) statt hart auf die Arztpraxis zugeschnitten -- aktives Branchen-Profil im Leitstand/Dashboard sichtbar -- README-Einleitung an den Profil-Umbau angepasst - -### Leitstand & Zugriff -- mehrere gleichzeitige Leitstand-Zugänge (`LEITSTAND_ZUGAENGE`), z. B. ein - eigenes Notdienst-Konto neben dem Praxis-Konto -- Abmelden-Button im Leitstand, zusätzlich per `?logoff` in der URL auslösbar -- eingeloggter Nutzer im Header sichtbar; Verlauf zeigt jetzt, wer eine Karte - verschoben hat -- Modell-Dropdown listet lokale Ollama-Modelle live statt fest im Code -- Dringlichkeitsfarben getauscht und zentralisiert; `/api/anrufe` und - `/api/verlauf` werden jetzt gecacht (Performance) - -### Testzugang & Sicherheit -- signierte, 1h gültige Testzugangslinks für den Leitstand ("agent-one - test"-Kommando) -- Sicher-Modus bei Testzugang erzwungen, nicht abschaltbar -- Rufnummern bei Testzugang serverseitig maskiert statt nur per CSS geblurrt - (vorher im Rohdaten-Response sichtbar) - -### Zuverlässigkeit -- Störungswache (`pipe.monitor`) + Verschlüsselung-at-rest dokumentiert -- Log-Rotation für Rohlogs + Aufräumen alter Verlauf-Archivkopien -- Linux-Setup-Hinweis in der README ergänzt - -### Sonstiges -- echte Aufnahme statt Sprachsynthese als Ansage möglich (`ANSAGE_TTS=datei`) -- Nextcloud-Profil, LinkedIn und Telefonnummer (internationales Format) im - Footer - ## v1.0.0 — 2026-08-25 Erste stabile Version. Läuft produktiv (mit Testdaten) auf macOS/Apple diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index 605f369..0000000 --- a/HANDOFF.md +++ /dev/null @@ -1,401 +0,0 @@ -# Handoff — Live-Telefonagent (laufend, zuletzt aktualisiert 2026-09-17) - -## Stand in einem Satz - -Astra ist live auf zwei Wegen erreichbar: direkt über Durchwahl 9 und über die Hauptnummer mit **Taste 9 während der Ansage** (Plusnet → FreeSWITCH → `dialog/pipecat_bootstrap.py` → WebSocket → Astra auf minim4-1, Modell `qwen3.5:4b`). Beides am 2026-09-17 mit echten Anrufen bestätigt. Größter offener Punkt: **Sprachqualität am Telefon ist schlecht** (noch nicht untersucht). - -## 2026-09-17 (Abend) — Taste 9 auf der Hauptnummer, Astra-Fakten, Offenes - -### Taste 9 während der Ansage → Astra (Commit `7c85f91`) - -`00_praxis_ab.xml` spielt `telefon/ansage.wav` jetzt per -`play_and_get_digits` (Regex `^9$`, 200 ms Nachlauf) statt `playback` und -ruft danach `execute_extension hauptnummer_taste_${hauptnummer_taste} XML -hauptnummer_menue` auf. Nur `hauptnummer_taste_9` existiert dort → -`socket 127.0.0.1:8095 async full`, identisch zu DW9. Ohne Taste findet -`execute_extension` nichts und die Aufnahme startet wie bisher. - -- Eigener Kontext `hauptnummer_menue` (`dialplan/hauptnummer_menue.xml`, - Top-Level wie `agentzwei.xml`), weil der Catch-all in `public` die Wahl - sonst verschluckt. Vorlage `telefon/freeswitch/hauptnummer_menue.xml.tpl`, - eingespielt von `einrichten.sh`. -- DW3 und DW9 unverändert (DW3 spielt weiter `playback`, keine Tastenwahl). -- **Andere Taste** (z. B. 5) bricht die Ansage ab und startet sofort die - Aufnahme - bewusst so gelassen, bei Bedarf ändern. -- Kein `hangup` nach `socket`: läuft der Bootstrap nicht, landet der Anrufer - auf dem Anrufbeantworter statt in der Stille. -- Verifiziert: Loopback (Taste 9 → Astra-Begrüßung, Taste 5 → Aufnahme) und - echter Anruf durch den Nutzer ("klappt super"). - -**Achtung beim Testen:** Jeder Loopback-Test, der bis zur Aufnahme kommt, -legt eine echte Datei in `telefon/eingang/` ab, die `pipe.watch` nach -wenigen Sekunden als Anruf verarbeitet. Sofort löschen oder vor der Aufnahme -auflegen (`uuid_kill`). - -### Notruf-Ton: eingebaut und auf Wunsch wieder entfernt - -Zwischendurch lief auf der Hauptnummer ein 54-s-Musikton -(`~/Downloads/MARTIN-Notruf_Ton.m4a`) vor der Ansage mit Taste 1 → Astra -(`83b576f`), dann eine Telefonband-Aufbereitung (`60b6154`, zurückgenommen -in `4e7eeab`). Der Nutzer wollte die alte Ansage zurück: komplett revertiert -in `c9bf40d`, Repo und Live-Dialplan waren danach identisch mit Tag -`vor-notruf-ton-2026-09-17` bzw. `ablage/sicherung-vor-notruf-ton-2026-09-17/`. -Erkenntnis falls es wiederkommt: der Ton ist bass-lastige Musik (Energie fast -komplett unter 300 Hz, im Telefonband nur -25 dB) und klingt am Telefon -schlecht - A-law war nicht die Ursache. - -### Astra-Fakten (minim4-1) - -- **Modell am Telefon: `qwen3.5:4b`** (nicht mehr `granite4.2:8b`), belegt - im Log (`'title': 'Antwort formulieren', 'detail': 'qwen3.5:4b'`) und über - `https://minim4-1.tail0f2cb2.ts.net/api/status`. -- **Latenz:** erste Antwort pro Anruf ~5,8 s, danach ~2,3 s - das Vorwärmen - beim Verbindungsaufbau reicht offenbar nicht ganz. -- **Log:** `~/Desktop/martin-voice-interface/.runtime/server.log` (Prozess - `uv run python -m astra.server`, gestartet 2026-09-17 15:29). - `/private/tmp/astra_server.log` ist seit 2026-09-13 tot. -- **SSH auf minim4-1** ist seit 2026-09-17 an ("Entfernte Anmeldung"), - Schlüssel-Login von minim4-2 funktioniert (`ssh minim4-1`). - -### Offen - -1. **Sprachqualität am Telefon schlecht** (Nutzer, 2026-09-17, nach Anrufen - über Taste 9) - nicht untersucht. Kandidaten: TTS (`LocalPocketTTSService`) - → Resampling auf 8 kHz, `uuid_audio_stream`/Playback-Kette im Bootstrap, - Codec (Leitung läuft PCMA). -2. **IP-Wechsel-Erkennung** in `starten.sh` noch nie real ausgelöst (nur der - Profil-Neustart von Hand getestet). -3. **`agentzwei.xml`:** Kommentar sagt "AgentTwo begraben, register=false", - eingestellt ist `register=true` und der Trunk ist REGED - Anrufe auf DW2 - laufen in einen Kontext ohne Backend. Nicht angefasst. -4. Störungs-Alarm weiterhin nur lokal auf dem Mac mini (siehe 2026-09-14). - -**Diagnose-Tipp (Fehlalarm am 2026-09-17):** "DW9 landet auf der Zentrale" -war eine verwählte Hauptnummer. Welche Nummer wirklich gewählt wurde, steht -pro Anruf im FreeSWITCH-Log: -`grep -a "Regex .*\[dw9_rufagent\]" /opt/homebrew/var/log/freeswitch/freeswitch.log | tail` -- DW9 = `sip:+49210378916179@ipfonie.de`, Hauptnummer = `sip:+4921037891617@ipfonie.de`. - -## 2026-09-17 — Leitung stumm nach IP-Wechsel, Watchdog-Kinder sterben - -**Symptom:** Durchwahl 9 (und die ganze Leitung) nahm nicht ab. Letzter -eingehender Anruf im FreeSWITCH-Log: 2026-09-15 18:05. Trunks trotzdem -REGED/UP. - -### 1. Veraltete öffentliche IP im Contact - -FreeSWITCH ermittelt die externe IP per STUN nur beim Laden der XML -(`vars.xml`, `stun-set`). Contact an Plusnet war noch `9.246.125.72`, -tatsächlich `217.142.18.120` (Zwangstrennung, 05:17 kurz 408/DOWN). -Registrierung und Pings gehen weiter raus, eingehende INVITEs laufen ins -Leere - kein Fehler im Log. - -**Fix:** `starten.sh` vergleicht bei jedem Watchdog-Lauf -`fs_cli -x 'stun stun.freeswitch.org'` mit `Ext-SIP-IP` des -external-Profils; bei Abweichung und 0 Gesprächen `reloadxml` + -`sofia profile external restart`. Nach einem IP-Wechsel ist die Leitung -also bis zu 5 min stumm. Profil-Neustart von Hand getestet (Trunks sofort -wieder REGED); der Abweichungsfall selbst noch nicht real ausgelöst. - -### 2. launchd beendete alles, was starten.sh im Hintergrund startet - -Vom Watchdog gestarteter Pipecat-Bootstrap band Port 8095 und war -Sekunden später weg, ohne Log. Ursache: launchd räumt nach Jobende die -Prozessgruppe ab. Betraf auch `pipe.monitor`/`pipe.watch`/`pipe.server` - -Fix 3 vom 2026-09-14 (Störungswache zuerst) griff unter launchd also nie. -FreeSWITCH überlebt, weil es sich selbst abkoppelt. - -**Fix:** `AbandonProcessGroup` in -`~/Library/LaunchAgents/net.dillenberg.fonagent.plist` (liegt **nicht** im -Repo), per `launchctl bootout`/`bootstrap` neu geladen. Verifiziert: -Bootstrap per Watchdog gestartet, lebt nach Jobende weiter. - -## 2026-09-14 — Leitung nach Neustart ~80 min tot: Watchdog bekam nichts hoch - -**Symptom:** Nach einem Neustart des Mac mini (~12:39) blieb die Leitung -(PROFIL=aufzug-notdienst) tot, bis ~13:58 von Hand gestartet wurde. Der -launchd-Watchdog (`~/Library/LaunchAgents/net.dillenberg.fonagent.plist`, -alle 5 min `starten.sh`) versuchte es 11-mal: jeweils "Backgrounding.", -dann "fs_cli antwortet nicht", exit 1. `freeswitch.log` blieb unberührt, -kein Crash-Report. Laut `autostart.log` hat der Watchdog FreeSWITCH **noch -nie** erfolgreich gestartet - bis dahin lief es offenbar immer manuell. - -Drei Ursachen, drei Fixes: - -### 1. FreeSWITCH stirbt unter launchd an SIGPIPE (Commit `f785ab3`) - -macOS' Datenschutz "Lokales Netzwerk" gilt für launchd-Prozesse, nicht für -Terminal-Shells: jedes Senden an eine LAN-Adresse (192.168.x) endet dort mit -`EPIPE`, Internet und 127.0.0.1 gehen. FreeSWITCH fragt beim Start per NAT-PMP -den Router (`192.168.2.1:5351`) - auch mit `-nonatmap`, das schaltet nur das -Port-Mapping ab, nicht die Erkennung. So früh ignoriert FreeSWITCH SIGPIPE noch -nicht -> Prozess tot ~0,5 s nach dem Backgrounding. - -Belegt mit einer zweiten FreeSWITCH-Instanz (eigene Ports, keine Gateways) über -einen temporären LaunchAgent: `-nc -rp -nonatmap` stirbt reproduzierbar, -`-nf` endet mit exit 141 (SIGPIPE), mit `-nonat` läuft sie. Unified Log im -Todesmoment: `UserEventAgent: Got local network blocked notification`. Ein -Python-UDP-Test unter launchd: Router -> Errno 32, 9.9.9.9 -> ok. - -**Fix:** `-nonat` im FreeSWITCH-Aufruf in `starten.sh`. Kein Verlust - der -Router hat NAT-PMP nie beantwortet (`nat_map status`: UNKNOWN), die externe IP -kommt per STUN. - -**Grenze, die bleibt:** Alles, was der Watchdog startet, erreicht **keine -LAN-Geräte**. Heute unkritisch (Trunks übers Internet, Sockets auf 127.0.0.1, -keine lokalen Telefone registriert). Kommen lokale SIP-Telefone o. Ä. dazu, -bricht das unter launchd wieder - dann nicht lange suchen. - -**Noch nicht unter echten Bedingungen verifiziert:** `starten.sh` selbst hat -FreeSWITCH mit `-nonat` noch nicht per Watchdog gestartet (solange es läuft, -überspringt das Skript es). Test: FreeSWITCH stoppen, dann -`launchctl kickstart gui/$(id -u)/net.dillenberg.fonagent` - ~1 min Ausfall. - -### 2. Pipecat-Bootstrap brach mit ModuleNotFoundError ab (Commit `907f511`) - -`starten.sh` startete `dialog.pipecat_bootstrap` mit System-`python3`, dem -`gevent`/`greenswitch` fehlen. Jetzt `.venv/bin/python`. Die übrigen -`pipe.*`-Dienste laufen weiterhin mit System-`python3` (dort vollständig). - -### 3. Störungswache lief genau beim Ausfall nicht (Commit `6db89af`) - -`pipe.monitor` wurde erst nach Trunk UP gestartet - kam FreeSWITCH nicht hoch, -gab es auch keine Wache und keinen Alarm. Jetzt startet sie als Erstes in -`starten.sh`, noch vor der Vorabprüfung. `pipe.dienste` meldet "FreeSWITCH -läuft nicht" bzw. "Trunk DOWN" selbst. - -**Offen:** Der Alarm ist nur eine lokale macOS-Benachrichtigung (`pipe/alarm.py`, -osascript) auf dem Mac mini - wenn dort niemand vor dem Bildschirm sitzt, sieht -ihn keiner. Für eine Notdienst-Leitung bräuchte es einen Push-Kanal nach außen. - -## 2026-09-13 (Tag) — Zwei Bugs gefunden und behoben - -### 1. Durchwahl 9 landete bei der Praxis-Zentrale statt bei Astra - -**Symptom:** externer Anruf auf +49 2103 78916179 (Durchwahl 9, "rufagent" im -Fonial-Portal, Status Online) klingelte durch, aber es meldete sich die -Praxis-Ansage (`00_praxis_ab.xml`) statt Astra. - -**Untersuchung:** `${sip_h_X-ORIGINAL-DDI-URI}` kam korrekt mit -`sip:+49210378916179@ipfonie.de` an — die DDI-Erkennung selbst war NICHT das -Problem (anders als zunächst vermutet). Es gab schlicht **keine Dialplan-Regel, -die auf diese DDI reagiert** — nur `00_dw3_warten.xml` (reagiert exklusiv auf -Durchwahl 3) existierte, alles andere fällt durch zu `00_praxis_ab.xml` -(`^.*$`, kein `continue="true"`, matcht daher wortwörtlich jede Nummer). - -**Erster Fix-Versuch schlug fehl, zweiter Grund gefunden:** eine neue Regel -in einer Datei namens `00_rufagent_astra.xml` wurde nach `reloadxml` immer -noch nicht erreicht — der Dialplan-Trace (`fs_cli -x "originate ... &echo()"`- -Anrufe mit `call_debug=true` oder `[INFO]`-Log direkt mitlesen) zeigte, dass -die Regel im geparsten Dialplan gar nicht auftauchte. Grund: `public.xml` -bindet den ganzen Ordner per `` -**alphabetisch** ein, und `00_praxis_ab.xml`s Catch-all kommt alphabetisch -VOR `00_rufagent_astra.xml` (`p` < `r`) und beendet die Dialplan-Auswertung, -bevor die eigene Regel überhaupt geladen wird. - -**Fix:** Datei umbenannt zu `00_dw9_rufagent.xml` (`d` < `p`, sortiert direkt -hinter `00_dw3_warten.xml` und vor `00_praxis_ab.xml`). Als Vorlage ins Repo -übernommen: `telefon/freeswitch/dw9_rufagent.xml.tpl` + `DW9_DDI` in `.env` -+ neuer Block in `telefon/freeswitch/einrichten.sh` (analog zum -DW3-Block) — die Live-Config kommt jetzt wieder komplett aus dem Repo, -reproduzierbar per `einrichten.sh`. - -**Lehre für weitere Durchwahlen:** Jede neue `dialplan/public/*.xml`-Regel, -die vor `00_praxis_ab.xml` greifen soll, MUSS einen Dateinamen bekommen, der -alphabetisch vor `p` einsortiert (Konvention: `00_dwN_.xml`, `N` als -Ziffer direkt nach `00_dw`). Sonst wird sie stillschweigend nie erreicht - -kein Fehler, kein Log-Eintrag, sie taucht im Dialplan-Trace einfach nicht auf. - -### 2. Telephone.app kann eine DDI-Registrierung stehlen - -Telephone.app (macOS-Softphone) lief auf minim4-1 mit den SIP-Zugangsdaten -einer der Fonial-Durchwahlen für manuelles SIP-Debugging (siehe -`/tmp/telephone_call.pcap`-Mitschnitt dort). Läuft es gleichzeitig mit -FreeSWITCHs eigener Registrierung derselben Durchwahl, ist nicht -deterministisch, wer den Anruf bekommt - siehe auch die Warnung in -`telefon/starten.sh` ("Telephone.app LAEUFT - beenden, sonst -Registrierungskonflikt"). Vor jedem externen Testanruf prüfen: -`pgrep -xl Telephone` auf der Maschine, auf der es installiert ist (hier: -minim4-1, nicht minim4-2!), und bei Bedarf beenden. - -## GELÖST 2026-09-13: keine fertige Antwort am Telefon - -Realer externer Testanruf (2026-09-13, ~12:14 Uhr) mit echtem Sprecher: - -- Audio kommt sauber an (Peak ~3000, kein Stille-Artefakt wie bei - Loopback/Selbstanruf-Tests). -- STT transkribiert korrekt: "kannst du mich hören?", danach "Hallo." -- Astra spielt beide Male nur den Zwischenbescheid **"Einen Moment bitte."** - - Danach läuft die eigentliche LLM-Antwort (`granite4.2:8b` über Ollama, - `NativeOllamaService`) im Astra-Log als `'phase': 'running'` weiter, - wird aber nie fertig: - - 1. Versuch: nach 4814 ms durch neue erkannte Nutzer-Sprache - (`VADUserTurnStartStrategy`) abgebrochen (`'phase': 'cancelled'`) - - der Anrufer hat vermutlich aus Ungeduld nochmal "Hallo" gesagt. - - 2. Versuch: nach 6002 ms abgebrochen, weil der Anrufer aufgelegt hat - (`Anruf beendet` direkt danach im Log, keine erneute Nutzer-Sprache). - - In beiden Fällen: **keine einzige echte Antwort hat es bis zur TTS - geschafft**, nur der Zwischenbescheid. - -**Hypothese:** Kein Barge-in/Echo-Problem (anders als in der vorherigen -Handoff-Version vermutet) - hier ist die reale Sprache eindeutig neu, kein -Echo-Artefakt. Verdacht: `granite4.2:8b` per Ollama ist für die -Telefon-Pipeline schlicht zu langsam (>5s ohne fertige Antwort), der -Anrufer gibt vorher auf. Zu prüfen morgen: -- Ollama-Antwortzeit isoliert messen (gleicher Prompt, gleiches Modell, - ohne Telefon-Pipeline drumherum) - liegt es am Modell/Backend oder an - Rechenlast durch parallel laufende STT/TTS-Modelle auf demselben Mac? -- Ob `ASTRA_OLLAMA_URL`/`ASTRA_OLLAMA_BACKENDS` für die Telefonie-Pipeline - auf ein schnelleres/entlastetes Backend zeigen sollte (siehe README - "Ollama-Backend wählen"). -- Ob ein kürzerer/schnellerer Zwischenbescheid-Rhythmus (z. B. alle 2-3s - ein weiteres "Moment noch") das gefühlte Hängen entschärfen würde, während - am eigentlichen Latenzproblem gearbeitet wird. - -**Root Cause bestätigt:** `granite4.2:8b` war zwischen dem Server-Start -(01:50 Uhr) und dem Testanruf (12:14 Uhr) aus Ollama verdrängt worden, trotz -`keep_alive: -1` in `astra/core.py::build_request` - `ollama ps` zeigte -davor `"models": []`. Direkt gemessen: kalt `load_duration` ~12,2s, warm -Gesamtdauer <0,9s. Der Anrufer wurde ungeduldig/legte auf, lange bevor die -eigentliche Generierung (nach dem Laden nur ~1s) fertig war. - -**Fix:** `astra/server.py::telefon_inbound` feuert jetzt beim -Verbindungsaufbau parallel zur Begrüßung einen Wegwerf-Generate-Aufruf an -Ollama (`_warm_ollama`, fire-and-forget, Fehler werden ignoriert) - der -Kaltstart-Tax landet dadurch während die Begrüßung läuft, nicht während der -ersten echten Antwort. Verifiziert mit echtem externen Testanruf: LLM-Zeit -1355 ms, vollständige Antwort kam durch, keine Abbrüche mehr. Commit -`a66629a` in `martin-voice-interface`. - -**Falls das Problem wiederkehrt** (z. B. weil ein anderer Ollama-Verbraucher -zwischen Anrufen ein anderes Modell lädt und `granite4.2:8b` trotzdem wieder -verdrängt wird): `curl http://127.0.0.1:11434/api/ps` vor einem Testanruf -prüfen, ob das Modell geladen ist. - -## Weiterhin offen (aus der vorherigen Handoff-Fassung, unverändert) - -### Sitzungs-Hänger-Bug (Astra `busy: true` bleibt hängen) - -Noch nicht erneut beobachtet/verifiziert heute, aber nicht aktiv behoben - -weiter im Auge behalten: `sessions`-Gate in `astra/server.py` könnte bei -einem nicht sauber geschlossenen WebSocket (z. B. hartes `hupall`) hängen -bleiben, siehe `run_telephony_call`/`_run_pipeline`s Cleanup-Pfad. - -## Werkzeuge/Referenzen - -- **Test-Nummer** (externe echte Rufnummer, Astra-Testleitung): - **+49 2103 78916179** (Durchwahl 9 / "rufagent" im Fonial-Portal). Vor dem - Test: Telephone.app auf minim4-1 beenden, falls es läuft - (`pgrep -xl Telephone`). -- **Loopback (ext. 7501, kein echtes Telefon)**: nur zum Prüfen der - Signalisierung/Verkabelung geeignet, liefert IMMER Stille (Peak ~1) - kein - Ersatz für einen echten Audiotest. - `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'originate loopback/7501 &park()'"` -- **Selbstanruf** (`dialog.anrufen 2`): unzuverlässig, liefert ebenfalls nur - Stille (bestätigt 2026-09-13) - nicht mehr nötig jetzt, wo DW9 extern - erreichbar ist. -- **Nach jedem Testanruf auflegen**: - `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'hupall NORMAL_CLEARING'"` -- **FreeSWITCH-Dialplan neu laden** (nach Änderungen in `dialplan/public/`, - kein Neustart nötig): - `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'reloadxml'"` -- **Astras Live-Log**: `~/Desktop/martin-voice-interface/.runtime/server.log` - auf minim4-1 (Stand 2026-09-17; `/private/tmp/astra_server.log` ist alt). - Bei Zweifel, wohin ein laufender Prozess loggt: - `lsof -p -a -d 0,1,2`. -- **Astras aktives Modell**: `curl -s https://minim4-1.tail0f2cb2.ts.net/api/status`. -- **Hauptnummer-Menü ohne Telefon testen** (Loopback in den public-Kontext, - Taste per `uuid_recv_dtmf` auf das B-Bein; Aufnahme-Warnung oben beachten): - `fs_cli -P 8022 -x 'originate loopback/5555/public &park()'`, dann - `fs_cli -P 8022 -x "uuid_recv_dtmf 9"`. -- **Bootstrap-Log**: `fonagent-one/telefon/pipecat_bootstrap.log` auf - minim4-2. -- **ESL-Port** auf minim4-2 ist **8022**, nicht der Standard 8021. -- **Astra neu starten** (siehe vorherige Handoff-Fassung im Git-Log für den - vollständigen Befehl mit `ASTRA_TELEFON_SECRET`/`ASTRA_LOG_LEVEL`). - -## Zugangsdaten/Config - -- `.env` auf minim4-2 im Projekt (gitignored) — SIP-Zugänge für `plusnet`, - `agentzwei`, `agentzwei_sbc`, `ASTRA_TELEFON_WS_URL`, jetzt auch - `DW9_DDI=49210378916179`. -- `ASTRA_TELEFON_SECRET` — nur als Laufzeit-Env-Var gesetzt, nicht in einer - Datei auf minim4-1 abgelegt. Muss auf beiden Seiten übereinstimmen. - -## GELÖST 2026-09-13 (Folgefehler): Begrüßung komplett stumm nach dem Cap-Fix - -Der Cap-Fix oben (Puffer auf 12s angehoben) legte einen zweiten, tieferen Bug -frei: `TTSStoppedFrame` erreicht `serialize()` in diesem Transport **nie** - -per Frame-Type-Tracing bestätigt, `FastAPIWebsocketOutputTransport` reicht an -den Serializer nur `OutputAudioRawFrame` und (bei Barge-in) `InterruptionFrame` -durch, `TTSStoppedFrame` wird intern fürs "Bot hat aufgehört zu sprechen" -verbraucht und nie weitergereicht. Der `if isinstance(frame, TTSStoppedFrame)`- -Zweig war also von Anfang an toter Code - geflusht wurde bisher ausschließlich -über die (zu klein bemessene) Cap. Mit der korrigierten, viel höheren Cap -erreichte eine normal lange Antwort (3-4s) diese nie mehr - und ohne den -(toten) TTSStoppedFrame-Pfad flusste dann gar nichts mehr: Astra loggte -Begrüßung + Transkript ganz normal, aber bei FreeSWITCH kam kein einziges -Playback-Event an. - -**Fix:** Flush jetzt über ein Inaktivitäts-Timeout (300ms ohne neuen -Audio-Frame) statt über `TTSStoppedFrame`. Da der Idle-Flush aus einem -Hintergrund-Task feuert (nicht aus `serialize()`s Rückgabewert), braucht er -einen eigenen Sendeweg - der Serializer nimmt jetzt zusätzlich zu `notify` -ein `send`-Callable (`websocket.send_text`). Commit `0a0c8d5` in -`martin-voice-interface`. - -Live verifiziert (Loopback-Test): 2,88s gesendet, FreeSWITCH spielt 3,02s -vollständig ab ("done playing file") - keine Stille, kein Abschneiden mehr. - -## GELÖST 2026-09-13 (weitere Folgefehler): zu lange Antworten + hackige Wiedergabe - -Nach dem Idle-Flush-Fix zwei weitere Probleme live gemeldet: - -1. **Antworten zu lang** - der Prompt allein ("1-2 kurze Sätze") wurde vom - Modell ignoriert, Antworten liefen auf 2-3 Sätze und lang genug, um die - 12s-Puffergrenze zu reißen. Fix: harte `num_predict`-Grenze - (`Settings.telefon_max_tokens`, Standard 40, `ASTRA_TELEFON_MAX_TOKENS` - env-einstellbar) statt nur einer Bitte im Prompt. Commit `4e6f266`. -2. **Wiedergabe "hackig"** - der 300ms-Idle-Flush (siehe vorheriger Eintrag) - feuerte bei Mehrsatz-Antworten fast immer VOR dem nächsten Satz, weil die - Pause zwischen zwei TTS-Satz-Chunks (das LLM muss den nächsten Satz erst - zu Ende streamen) regelmäßig über 300ms liegt - jeder Satz kam so als - eigene, einzeln abgespielte Nachricht statt als eine zusammenhängende - Antwort. Fix: `flush_now()` auf dem Serializer, ausgelöst über - `run_telephony_call`s `notify()`-Hook genau dann, wenn die Pipeline - meldet, dass der Bot wirklich fertig gesprochen hat - (`BotStoppedSpeakingFrame` via den `'state': 'listening'`-Callback) - das - ist das einzige verlässliche "Antwort fertig"-Signal, da `TTSStoppedFrame` - selbst `serialize()` nie erreicht (siehe voriger Eintrag). Der - Idle-Timeout ist jetzt nur noch Fallback (4s statt 300ms). Commit - `42acca4`. - -Live verifiziert: Begrüßung flusht jetzt ~2ms nach echtem Sprechende (statt -vorher 300ms Verzögerung), vollständig, keine Aufteilung. - -**Offen für nächsten Test:** ob Mehrsatz-Antworten jetzt tatsächlich flüssig -klingen (nur mit Loopback getestet, das produziert keine echte Sprache für -einen mehrteiligen Dialog) - braucht einen echten Anruf mit echtem Gespräch. - -## GELÖST 2026-09-13 (weiterer Folgefehler): Anruf trennt nach ~50s ohne Antwort - -Erster echter Mehrsatz-Dialog lief mehrere Runden gut (kurze Antworten, -zusammenhängende Wiedergabe) - dann zweimal hintereinander: VAD hat ausgelöst -(`min_volume=0.3` reagiert absichtlich auch auf leise Signale, siehe -vorheriger Eintrag), aber Nemotron hat für die ganze Runde nichts -transkribiert (`Nemotron result: final, 0 characters`). Ein leeres Transkript -erreicht `LLMUserAggregator` nie als echte Nutzer-Nachricht - keine -LLM-Anfrage, keine Antwort, keine Rückmeldung. Für den Anrufer wirkt das wie -eine tote Leitung; nach zwei solchen Runden hat er aufgelegt. - -**Fix:** `NemotronSTTService` bekommt jetzt optional `no_speech_phrases` -(`astra.services.NO_SPEECH_PHRASES`, z. B. "Wie bitte?", "Kannst du das -wiederholen?") und spricht bei leerem finalem Transkript eine davon über -`TTSSpeakFrame`. Nur für die Telefonie verdrahtet (Browser hat schon einen -sichtbaren "listening"-Zustand). Commit `c8946d0`. - -**Noch offen:** ob `min_volume=0.3` grundsätzlich zu locker ist (löst zu oft -auf Nicht-Sprache aus) - dieser Fix behandelt nur das Symptom (Anrufer bekommt -immer eine Reaktion), nicht die Ursache. Bei wiederholtem Auftreten: -`ASTRA_TELEFON_VAD_MIN_VOLUME` schrittweise erhöhen und beobachten, ob echte -leise Sprache dann noch erkannt wird. diff --git a/README.md b/README.md index 111c480..32aa600 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,30 @@ # Logpy:AgentOne -**v1.1.0** — siehe [CHANGELOG.md](CHANGELOG.md) +**v1.0.0** — siehe [CHANGELOG.md](CHANGELOG.md) -Anruf-Intake-Agent — nimmt Anrufe (Sprachnachrichten) entgegen, **transkribiert** +Praxis-Telefon-Agent — nimmt Anrufe (Sprachnachrichten) einer Hausarztpraxis entgegen, **transkribiert** sie lokal, **kategorisiert** das Anliegen und **legt** das Ergebnis strukturiert -ab (lokal und optional in Nextcloud). Für welchen Einsatzbereich (Hausarztpraxis, -24h-Notdienst, …) ist reine Konfiguration — siehe -[Branchen-Profil](#kategorien--branchen-profil). Zwei Beispiele liegen bei: -`praxis` (Hausarztpraxis) und `aufzug-notdienst`. +ab (lokal und optional in Nextcloud). -**Vollständig on-prem** — keine Anrufdaten verlassen den Rechner. +**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 > 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) +sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. + ## Stufen 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 ✅, - Verschlüsselung at rest ✅ (Betreiber-Pflicht: FileVault, siehe - [Datenschutz](#datenschutz-dsgvo)), Monitoring/Alerting bei Diensteausfall ✅ - (`pipe/monitor.py`, siehe unten). +3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅ — offen: + Monitoring/Alerting bei Diensteausfall, Verschlüsselung at rest. ## Bausteine @@ -38,62 +37,19 @@ ab (lokal und optional in Nextcloud). Für welchen Einsatzbereich (Hausarztpraxi | `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 | | `pipe/bewertung.py` | 👍/👎-Bewertung pro Anruf (Leitstand) | | `pipe/export.py` | Exportiert bewertete Anrufe als JSONL-Trainingsdaten | -| `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 | +| `prompts/categorize_de.txt` | Kategorisierungs-Prompt (deutsch, mehrsprachig-tauglich) | +| `telefon/` | Ansage, FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte | -## Kategorien & Branchen-Profil +## Kategorien -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. +`termin · rezept · ueberweisung · befund · verwaltung · beschwerden · notfall · +rueckruf · sonstiges` +Dringlichkeit: `niedrig · normal · hoch · notfall` ## Einrichtung @@ -180,7 +136,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 profile/$PROFIL/ansage.txt erzeugen +./telefon/ansage_bauen.sh # Ansage aus telefon/ansage.txt erzeugen ./telefon/freeswitch/einrichten.sh # Trunk + Dialplan in FreeSWITCH eintragen ./telefon/starten.sh # startet FreeSWITCH, Verarbeitung, Leitstand ``` @@ -236,37 +192,7 @@ 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. -### Durchwahl parallel zu einem Endgerät klingeln lassen - -Manche Provider (z. B. Fonial/Plusnet) klingeln bei einer Durchwahl **parallel** -an allen dafür registrierten Zielen — bei uns also gleichzeitig an AgentOne und -an einem echten Telefon/Softphone. Ohne Gegenmaßnahme gewinnt AgentOne das -Rennen aber immer: `answer()` ist im Dialplan die erste Aktion, es gibt keine -Verzögerung davor. Das Telefon klingelt dann faktisch nie, weil der Anruf schon -beim Assistenten gelandet ist, bevor am Gerät überhaupt ein Klingelzeichen -ankommt. - -`DW3_DDI` in der `.env` schaltet eine Wartezeit vor `answer()` frei -(`telefon/freeswitch/dw3_warten.xml.tpl`, deployed als `00_dw3_warten.xml` – -lädt alphabetisch vor `00_praxis_ab.xml`): erkennt der Dialplan die passende -Durchwahl, wartet er 20 Sekunden, bevor überhaupt abgenommen wird. Nimmt in der -Zeit jemand am registrierten Endgerät ab, bricht der Provider den Versuch bei -AgentOne per SIP-`CANCEL` ab — der Dialplan kommt gar nicht mehr bis zum -`answer()`. Nimmt niemand ab, läuft er nach den 20 Sekunden normal weiter zu -Ansage und Aufnahme, identisch zum Standard-Anrufbeantworter. - -Wichtig: die Durchwahl selbst kommt bei diesem Trunk nicht im -`destination_number` an (der zeigt bei Plusnet nur eine interne Trunk-Kennung, -unabhängig von der gewählten Nummer), sondern im SIP-Header -`X-ORIGINAL-DDI-URI` der eingehenden Einladung — nur der ist brauchbar, um DW3 -von anderen Anrufen zu unterscheiden. `DW3_DDI` erwartet die volle Rufnummer -inklusive Durchwahl-Ziffer(n), nur Ziffern, kein `+`. - -Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim -Anrufbeantworter wie bisher. - -Text ändern: `profile/$PROFIL/ansage.txt` bearbeiten (siehe -[Branchen-Profil](#kategorien--branchen-profil)), `ansage_bauen.sh` erneut +Text ändern: `telefon/ansage.txt` bearbeiten, `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. @@ -321,29 +247,6 @@ 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 👎 @@ -368,21 +271,7 @@ 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 `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/`) -> 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. +> **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. diff --git a/dialog/__init__.py b/dialog/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dialog/anrufen.py b/dialog/anrufen.py deleted file mode 100644 index 0c0302b..0000000 --- a/dialog/anrufen.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Outbound-Call-Trigger fuer die Pipecat-Testleitung. - -Ruft eine Nummer ueber den Plusnet-Trunk an und uebergibt sie nach Abheben an -dieselbe Bruecke wie die interne Testnebenstelle 7501 -(dialog/pipecat_bootstrap.py, Port 8095) - answer()+uuid_audio_stream+park() -laeuft dort identisch, unabhaengig davon ob der Anruf rein- oder rausgeht. Der -Dialplan-Umweg (Nebenstelle 7501) entfaellt hier: `&socket(...)` haengt den -ausgehenden Kanal direkt nach Abheben an den Bootstrap, ohne Dialplan-Lookup. - -Nutzung: python3 -m dialog.anrufen -""" - -import os -import sys - -import greenswitch - -FS_HOST = "127.0.0.1" -FS_PORT = int(os.environ.get("FS_PORT", "8022")) -FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon") -BOOTSTRAP_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095")) -# DW9 ("rufagent") - the only account confirmed capable of outbound so far. -# Needs "proxy" (R-URI domain, must be sip.plusnet.de - Plusnet 403s otherwise) -# split from "outbound-proxy" (actual packet routing, sbc.sip.plusnet.de) in -# its gateway XML - see sip_profiles/external/agentzwei_sbc.xml. -GATEWAY = os.environ.get("ANRUF_GATEWAY", "agentzwei_sbc") -CALLER_ID = os.environ.get("ANRUF_CALLER_ID", "+49210378916179") - - -def normalisiere(nummer: str) -> str: - """Wie pipe/kontakte.py: nur Ziffern, Deutschland-Annahme 0 -> 49, - kein '+' - so erwartet es der Trunk auch fuer DW3_DDI in der .env.""" - ziffern = "".join(zeichen for zeichen in nummer if zeichen.isdigit() or zeichen == "+") - if ziffern.startswith("+"): - return ziffern[1:] - if ziffern.startswith("00"): - return ziffern[2:] - if ziffern.startswith("0"): - return "49" + ziffern[1:] - return ziffern - - -def main(): - if len(sys.argv) != 2: - sys.exit("Nutzung: python3 -m dialog.anrufen ") - ziel = normalisiere(sys.argv[1]) - # The &socket(...) part MUST stay in single quotes: originate splits its - # arguments on spaces, so an unquoted "&socket(127.0.0.1:8095 async full)" - # reaches the channel as plain "socket(127.0.0.1:8095)" - "async full" - # silently dropped (seen in freeswitch.log's own EXECUTE line, 2026-09-13). - # Without "full", mod_event_socket refuses every command past sendmsg on - # an outbound listener, so pipecat_bootstrap's CUSTOM-event subscription - # ("event plain CUSTOM mod_audio_stream::play") is answered with - # "-ERR command not found" and no audio is ever played back. - befehl = ( - f"originate {{origination_caller_id_number={CALLER_ID}}}" - f"sofia/gateway/{GATEWAY}/{ziel} " - f"'&socket({FS_HOST}:{BOOTSTRAP_PORT} async full)'" - ) - print(f"Rufe {ziel} an ueber: {befehl}") - fs = greenswitch.InboundESL(host=FS_HOST, port=FS_PORT, password=FS_ESL_PASSWORD) - fs.connect() - antwort = fs.send(f"api {befehl}") - print(antwort.data) - - -if __name__ == "__main__": - main() diff --git a/dialog/pipecat_bootstrap.py b/dialog/pipecat_bootstrap.py deleted file mode 100644 index 327f599..0000000 --- a/dialog/pipecat_bootstrap.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Outbound-ESL bootstrap for the Pipecat phone-agent test line. - -FreeSWITCH's dialplan extension "agentzwei_pipecat_test" (destination_number -7501, dialplan/default/06_agentzwei_pipecat_test.xml, internal test-only, not -reachable from the PSTN) - and now also dialog/anrufen.py's outbound calls - -hand the channel here via `socket(127.0.0.1:PORT async full)`. This process's -job: answer(), tell mod_audio_stream to start streaming the call's audio to -Astra's /telefon/inbound, then stay on the line for as long as the call lasts -to actually play back what Astra sends - the conversation logic itself lives -entirely in Astra's Pipecat pipeline, over the WebSocket that mod_audio_stream -opens outbound to Astra. - -mod_audio_stream has no dialplan application, only the ESL API command -`uuid_audio_stream` (confirmed against the module's own source and by -testing). Confirmed by testing (2026-09-12): issuing `uuid_audio_stream` from -this same process - even over a second, separate ESL connection - reliably -fails the WebSocket connect for real external/gateway calls (instant -"connection error", no such issue for the loopback test line). Matches a -known mod_audio_stream issue (amigniter/mod_audio_stream#92): "It's an issue -with using uuid_audio_stream on the same call session uuid as the Python -script that's listening for the WebSocket, separating these processes -resolves the problem." So the command is shelled out to `fs_cli` as a -genuinely separate OS process, not issued from within this one at all. - -Second gap found by testing (2026-09-13): mod_audio_stream does NOT play -audio back on its own. Astra's `streamAudio` message only makes it write a -temp WAV file and fire a `mod_audio_stream::play` CUSTOM event containing that -path (audio_streamer_glue.cpp:290-303) - something has to catch that event and -actually play the file. Nothing in the module or the dialplan does this, so -this process stays on the call (instead of returning right after park()) and -plays each file back as the events arrive, until the call hangs up - with the -`playback` application over this same outbound socket. Unlike uuid_audio_stream -that is not an "api" command but an execute-style application call, exactly -like the answer()/park() that already work here, so it does not need the -separate-process detour. - -Getting those CUSTOM events delivered at all took two subscriptions plus one -workaround; see run() and event_body_json() for the details. -""" - -import json -import logging -import os -import subprocess -import sys - -import gevent -import greenswitch - -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") -logger = logging.getLogger("pipecat_bootstrap") - -BIND_ADDRESS = os.environ.get("PIPECAT_BOOTSTRAP_BIND", "127.0.0.1") -BIND_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095")) -ASTRA_WS_URL = os.environ.get("ASTRA_TELEFON_WS_URL", "") -STREAM_MIX_TYPE = "mono" -STREAM_SAMPLE_RATE = "16k" # matches NemotronSTTService's hard 16 kHz requirement on the Astra side -# Upper bound so a greenlet can never outlive a call FreeSWITCH itself forgot -# to tell us ended - failure mode is "one stray idle greenlet", not a runaway. -MAX_CALL_SECONDS = 30 * 60 - -FS_HOST = "127.0.0.1" -FS_PORT = os.environ.get("FS_PORT", "8022") -FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon") -FS_CLI = os.environ.get("FS_CLI_BIN", "/opt/homebrew/bin/fs_cli") - - -def fs_cli(command: str) -> str: - """Run one `api` command via `fs_cli` as its own OS process - see module - docstring for why commands for this call must not go over this process's - own outbound-socket connection.""" - result = subprocess.run( - [FS_CLI, "-H", FS_HOST, "-P", FS_PORT, "-p", FS_ESL_PASSWORD, "-x", command], - capture_output=True, text=True, timeout=10, - ) - return (result.stdout or result.stderr).strip() - - -def event_body_json(event) -> dict | None: - """Read a FreeSWITCH event's body (the part after the blank line) as JSON. - - greenswitch's ESLEvent.parse_data() only knows "key: value" lines, so an - event body - mod_audio_stream sends bare JSON, which has no ": " in it - - is never split off as its own field. It ends up appended to the value of - whatever header came last, which in plain format is always the body's own - "Content-Length". So `headers["Content-Length"]` reads e.g. - '84\\n\\n{"audioDataType":"wav","file":"/tmp/....wav"}' - the payload is - there, just glued to a number. Cut at the first "{" and parse that. - """ - raw = event.headers.get("Content-Length") or "" - start = raw.find("{") - if start == -1: - return None - try: - payload = json.loads(raw[start:]) - except (ValueError, TypeError): - return None - return payload if isinstance(payload, dict) else None - - -class PipecatBridge: - """One instance per call, per greenswitch's OutboundESLServer contract.""" - - def __init__(self, session): - self.session = session - self.call_id = getattr(session, "uuid", "?") - self.hung_up = gevent.event.Event() - - def on_play_event(self, event): - payload = event_body_json(event) - if payload is None: - logger.warning( - "Anruf %s: mod_audio_stream::play-Event ohne lesbaren JSON-Rumpf: %r", - self.call_id, event.headers, - ) - return - path = payload.get("file") - if not path: - logger.warning("Anruf %s: play-Event ohne Dateipfad: %r", self.call_id, payload) - return - # Same mechanism Logpy-AgentOne already uses successfully in - # production (dialplan `playback` app) - over this session's own - # connection, unlike uuid_audio_stream this is an "execute"-style - # application call (like answer()/park(), already proven to work - # here), not an "api" command. - self.session.playback(path, block=False) - logger.info("Anruf %s: spiele %s ab", self.call_id, path) - - def on_hangup(self, event): - self.hung_up.set() - - def run(self): - try: - # Two separate subscriptions, both required, in this order: - # - # `myevents` turns on this session's own event feed, but it does - # NOT mean "all events" - mod_event_socket hardcodes a whitelist - # of ~23 event ids there (mod_event_socket.c, parse_command(), - # "myevents" branch), all of them CHANNEL_*/DTMF/TALK. CUSTOM is - # not in it, so no module's CUSTOM event can ever arrive on a - # myevents-only socket, no matter its subclass. - # - # `event plain CUSTOM ` adds exactly that: it sets - # event_list[CUSTOM] plus a subclass entry in the listener's - # event_hash. It is purely additive - it does not reset the - # whitelist myevents just installed (only `noevents`/`nixevent` - # clear entries), so the CHANNEL_* events keep coming. The - # LFLAG_MYEVENTS flag stays set too, so the CUSTOM events we now - # get are still filtered down to this channel's own Unique-ID. - # - # Needs the socket to be "full" (dialplan/originate say - # `socket(host:port async full)`): mod_event_socket drops every - # command past this point for a non-full outbound listener. - self.session.myevents() - self.session.send("event plain CUSTOM mod_audio_stream::play") - self.session.linger() - self.session.register_handle("mod_audio_stream::play", self.on_play_event) - self.session.register_handle("CHANNEL_HANGUP", self.on_hangup) - self.session.answer() - antwort = fs_cli( - f"uuid_audio_stream {self.call_id} start {ASTRA_WS_URL} " - f"{STREAM_MIX_TYPE} {STREAM_SAMPLE_RATE} {self.call_id}" - ) - logger.info("Anruf %s: uuid_audio_stream gestartet, Antwort: %r", self.call_id, antwort) - self.session.park() - # Stay on the line - on_play_event needs to keep firing for the - # whole call, not just the first bot utterance. - self.hung_up.wait(timeout=MAX_CALL_SECONDS) - except Exception: - logger.exception("Anruf %s: Bootstrap fehlgeschlagen", self.call_id) - finally: - self.session.stop() - - -def main(): - if not ASTRA_WS_URL: - sys.exit("ASTRA_TELEFON_WS_URL fehlt in der Umgebung (.env) - Abbruch.") - logger.info("Pipecat-Bootstrap hoert auf %s:%s, Ziel: %s", BIND_ADDRESS, BIND_PORT, ASTRA_WS_URL) - server = greenswitch.OutboundESLServer( - bind_address=BIND_ADDRESS, bind_port=BIND_PORT, - application=PipecatBridge, max_connections=5, - ) - server.listen() - - -if __name__ == "__main__": - main() diff --git a/docs/MITGABE.md b/docs/MITGABE.md new file mode 100644 index 0000000..cc7753f --- /dev/null +++ b/docs/MITGABE.md @@ -0,0 +1,129 @@ +# Mitgabe für die Einrichtung auf einem zweiten Rechner + +Diese Datei ergänzt README (Einrichtung) und HANDOFF (Stand). Sie enthält das, +was auf der anderen Seite nicht im Repository ankommt: die Erfahrungswerte aus +dem ersten Volltest, die Stellen, an denen die Vorlagen unvollständig sind, und +die Reihenfolge, in der man prüft, ob es wirklich läuft. + +## 1. Was im Repo *nicht* liegt + +| Fehlt | Woher | +|---|---| +| `.env` | aus `.env.example` kopieren, Werte eintragen (Abschnitt 3) | +| Whisper-Modell (~1,6 GB) | `~/whisper-models/ggml-large-v3-turbo.bin`, Download siehe README | +| Ollama-Modell (~5,2 GB) | `ollama pull qwen3:8b` | +| Piper-Stimme | `~/piper-voices/…`, nur für die Ansage (Stufe 2) nötig | +| `ablage/`, `telefon/eingang/`, `dashboard.html` | Anruf- und Patientendaten, bleiben absichtlich lokal | +| FreeSWITCH-Konfiguration mit echtem SIP-Passwort | `telefon/freeswitch/einrichten.sh` schreibt sie aus der `.env` | + +Alle Namen, Rufnummern und Beispieldaten im Repo sind erfunden. „Praxis +Musterhausen" ist Platzhalter — auch im Screenshot. + +## 2. Der kurze Weg: Stufe 1 ohne Telefonie + +Telefonie (FreeSWITCH, SIP-Trunk) ist für die Erprobung nicht nötig und macht +die meiste Arbeit. Für einen ersten Durchlauf reicht: + +```bash +brew install ffmpeg whisper-cpp +brew install ollama && ollama serve & +ollama pull qwen3:8b +mkdir -p ~/whisper-models && cd ~/whisper-models +curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin +cd ~/praxis-telefon-agent && cp .env.example .env +python3 -m pipe.process_call test/rezept.wav --nummer 021031234567 +``` + +Läuft das durch (~25 s), liegt das Ergebnis unter +`ablage/JJJJ-MM-TT/HH-MM-SS_021031234567/`. Nextcloud, Deck, Leitstand und +Telefonie sind alle abschaltbar und kommen erst danach dran. + +## 3. Was die `.env.example` verschweigt + +Die Vorlage deckt nicht alle Schalter ab, die `pipe/config.py` liest. Die +Voreinstellungen greifen still — mit diesen Folgen: + +| Variable | Voreinstellung | Warum das wichtig ist | +|---|---|---| +| `STT_BACKEND` | `whispercpp` | `WHISPER_MODELL`/`WHISPER_COMPUTE` aus der Vorlage gelten **nur** für `faster`. In der Voreinstellung werden sie ignoriert. | +| `WHISPERCPP_MODELL` | `~/whisper-models/ggml-large-v3-turbo.bin` | Liegt das Modell woanders, bricht die Erkennung ab. | +| `WHISPERCPP_BIN` | `whisper-cli` | Muss im `PATH` liegen (Homebrew `whisper-cpp`). | +| `WHISPER_THREADS` | `8` | Auf kleineren Maschinen herunterdrehen. | +| `NEXTCLOUD_DECK` | *aus* | **Häufigste Verwirrung:** `DECK_TEILEN` und `DECK_STAPEL` stehen in der Vorlage, der Hauptschalter nicht. Ohne `NEXTCLOUD_DECK=1` entsteht kein Board. | +| `NEXTCLOUD_USERID` | = `NEXTCLOUD_USER` | Bei E-Mail-Login weicht die interne User-ID ab, dann zeigt der WebDAV-Pfad ins Leere. | +| `FREESWITCH_LOG` | `/opt/homebrew/var/log/freeswitch/…` | Apple-Silicon-Homebrew. Auf Intel-Macs `/usr/local/...`. Sonst bleiben die Telefon-Ereignisse im Leitstand leer. | +| `ABLAGE_LOKAL`, `TELEFON_ORDNER` | im Projektordner | Für eine Ablage auf einem anderen Volume hier umbiegen. | +| `WHISPER_PROMPT` | Praxis-Kontext | Steuert, wie zuverlässig Namen und Rufnummern erkannt werden. Bei anderem Einsatzzweck anpassen. | + +`starten.sh` nutzt außerdem `FS_ETC` (Voreinstellung `/opt/homebrew/etc/freeswitch`) +und `FS_PORT` (`8099`, weil 8021 auf dem Ursprungsrechner belegt war). Auf einer +frischen Maschine ist 8021 meist frei — dann entweder `FS_PORT=8021` setzen oder +den Port in `autoload_configs/event_socket.conf.xml` auf 8099 lassen. Beides +muss zusammenpassen, sonst antwortet `fs_cli` nicht. + +## 4. Prüfleiter — in dieser Reihenfolge + +1. **Erkennung:** `python3 -m pipe.process_call test/termin.wav --nummer 0…` + → `meta.json` enthält ein plausibles `transkript`. +2. **Kategorisierung:** `test/rezept.wav` → `kategorie: rezept`, + `test/notfall.wav` → `dringlichkeit: notfall`. Bleibt `kategorie` leer, + läuft Ollama nicht oder das Modell fehlt. +3. **Name und Rückrufnummer** stehen als eigene Felder in `meta.json` — nicht + nur im Fließtext von `anliegen_kurz` (siehe Falle 1 unten). +4. **Nextcloud:** Zugangsdaten eintragen, erneut einen Anruf verarbeiten, dann + `python3 -m pipe.resync` — meldet 0 Fehler. +5. **Deck:** `NEXTCLOUD_DECK=1` **und** `DECK_TEILEN` setzen. Ohne Freigabe legt + die Pipe Karten an, die Praxis sieht ein leeres Board. +6. **Leitstand:** `python3 -m pipe.server`, dann . +7. **Telefonie** zuletzt: `./telefon/starten.sh` prüft selbst vor und bricht mit + Klartext ab. + +## 5. Fallen, die uns beim ersten Volltest getroffen haben + +1. **Name und Rückrufnummer gingen still verloren.** Beide Felder fehlten in der + `required`-Liste des JSON-Schemas; das Modell ließ sie einfach weg, obwohl es + sie kannte. Behoben — aber beim Anpassen des Schemas in `pipe/categorize.py` + sofort wieder einbaubar. Immer gegen `test/notfall.wav` gegenprüfen. +2. **WebDAV ohne Wiederholung.** Ein einzelner träger Antwortlauf schickt den + Anruf in den manuellen Nachsync. Jetzt 3 Versuche mit Backoff, Timeout 60 s. +3. **Ordnername aus der geratenen statt der CLIP-Nummer.** Die Nummer kommt aus + der Telefonanlage, nicht aus dem Transkript. +4. **Stumme ffmpeg-/whisper-Fehler.** Jetzt mit Klartext — bei eigenen + Änderungen nicht wieder auf nackte `CalledProcessError` zurückfallen. +5. **Telephone.app streitet um dieselbe SIP-Registrierung.** Vor dem Start + beenden; `starten.sh` prüft das. +6. **Rotierende Provider-Adressen.** Löst der SIP-Hostname auf mehrere IPs auf, + bricht die Registrierung dauernd ab. `SIP_PROXY` auf einen Host setzen, der + auf genau eine IP zeigt. +7. **Leerzeichen im Projektpfad brechen die Aufnahme.** FreeSWITCHs `record` + zerlegt seine Argumente an Leerzeichen (`record + `). Liegt das Projekt z. B. unter + `~/Desktop/Server AI/…`, landet die Aufnahme als `…/Server.PCMA` neben dem + Projekt, der Eingang bleibt leer und es sieht aus wie ein Watcher-Fehler — + die Ansage läuft ja. `einrichten.sh` bricht deshalb bei Leerzeichen ab und + nennt den Symlink-Ausweg. Getroffen auf der zweiten Maschine, 23.08.2026. +8. **FreeSWITCH liest aus dem Cellar**, wenn `-conf` fehlt — dann überschreibt + jedes Homebrew-Upgrade Trunk und Dialplan. + +## 6. Was bewusst noch fehlt (vor Produktivbetrieb klären) + +- **Notfall löst keinen Alarm aus.** `dringlichkeit: notfall` bekommt ein rotes + Label und steht im Dashboard oben — kein Ton, keine Push, keine Mail. Eine + Pipe, die Notfälle stumm ablegt, ist für eine Praxis gefährlicher als gar + keine. Das ist der nächste Schritt, vor der Telefonie. +- **Keine Löschfristen**, keine Verschlüsselung at rest. +- **Leitstand im Netz** (`LEITSTAND_HOST=0.0.0.0`) verlangt `LEITSTAND_PASS` — + die Seiten zeigen Transkripte und Aufnahmen von Patienten. +- **Ansage** nennt 112 und weist auf die Aufzeichnung hin. Beides ist bei einer + Arztpraxis Pflicht und darf beim Umtexten nicht wegfallen. +- **Mehrsprachigkeit:** `WHISPER_SPRACHE=de` ist fest. Für EN/AR auf `auto`. + +## 7. Nützliche Rückmeldung + +Wenn auf der anderen Seite etwas abweicht, sind vor allem diese Punkte für uns +hier interessant: + +- andere Hardware/OS-Version und wie lange ein Anruf dann braucht (hier ~24 s), +- ob `qwen3:8b` bei 16 GB RAM neben laufender Telefonie noch passt, +- jede Stelle, an der README oder `.env.example` nicht gereicht haben — die + gehört dann dort hinein, nicht in diese Datei. diff --git a/docs/agentone-screen-1.jpg b/docs/agentone-screen-1.jpg new file mode 100644 index 0000000..52f9c6c Binary files /dev/null and b/docs/agentone-screen-1.jpg differ diff --git a/docs/logpy-agentone-1-1.jpg b/docs/logpy-agentone-1-1.jpg deleted file mode 100644 index 4da4c82..0000000 Binary files a/docs/logpy-agentone-1-1.jpg and /dev/null differ diff --git a/pipe/alarm.py b/pipe/alarm.py deleted file mode 100644 index a19ac2f..0000000 --- a/pipe/alarm.py +++ /dev/null @@ -1,27 +0,0 @@ -"""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/categorize.py b/pipe/categorize.py index 4763db4..d161060 100644 --- a/pipe/categorize.py +++ b/pipe/categorize.py @@ -15,33 +15,40 @@ import urllib.error import urllib.request from functools import lru_cache -from . import config, kategorien, modellwahl +from . import config, modellwahl -KATEGORIEN = kategorien.KATEGORIEN -DRINGLICHKEITEN = kategorien.DRINGLICHKEITEN +KATEGORIEN = [ + "termin", "rezept", "ueberweisung", "befund", + "verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges", +] +DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"] - -@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("#") - ] +# 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", +)] def _notfall_sicherheitsnetz(transkript: str, d: dict) -> dict: - ziel = kategorien.SICHERHEITSNETZ_KATEGORIE - if d.get("kategorie") != ziel and any(m.search(transkript) for m in _sicherheitsnetz_muster()): - d["kategorie"] = ziel + if d.get("dringlichkeit") != "notfall" and any(m.search(transkript) for m in _NOTFALL_MUSTER): + d["kategorie"] = "notfall" d["dringlichkeit"] = "notfall" return d @@ -237,11 +244,11 @@ def _bereinige(d: dict) -> dict: d["kategorie"] = "sonstiges" if d.get("dringlichkeit") not in DRINGLICHKEITEN: d["dringlichkeit"] = "normal" - # 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: + # 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": d["dringlichkeit"] = "notfall" d.setdefault("anrufer_name", None) d.setdefault("rueckrufnummer", None) diff --git a/pipe/config.py b/pipe/config.py index 4a26b3a..0f0741e 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -83,21 +83,9 @@ OPENROUTER_KEY = os.environ.get("OPENROUTER_KEY", "") # hinterlegten (siehe pipe/modellwahl.py) - leer lassen wenn nicht gebraucht. OPENROUTER_MODELL = os.environ.get("OPENROUTER_MODELL", "") -# --- 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" +PROMPT_DATEI = WURZEL / "prompts" / os.environ.get( + "PROMPT_DATEI", "categorize_de.txt" +) # --- Ablage ------------------------------------------------------------------ ABLAGE_LOKAL = Path(os.environ.get("ABLAGE_LOKAL", str(WURZEL / "ablage"))) @@ -117,28 +105,6 @@ LEITSTAND_PORT = int(os.environ.get("LEITSTAND_PORT", "8088")) # auf den Seiten stehen Transkripte und Aufnahmen von Patienten. LEITSTAND_USER = os.environ.get("LEITSTAND_USER", "praxis") LEITSTAND_PASS = os.environ.get("LEITSTAND_PASS", "") -# Weitere Konten zusaetzlich zum obigen (z.B. ein eigenes Konto fuer den -# Notdienst neben dem Praxis-Konto) - kommagetrennt "nutzer:passwort", -# z.B. "notdienst:Berg,spaeter:nochwas". -LEITSTAND_ZUGAENGE = os.environ.get("LEITSTAND_ZUGAENGE", "") - - -def leitstand_konten() -> dict[str, str]: - """Alle gueltigen Leitstand-Logins als {nutzername: passwort}.""" - konten = {} - if LEITSTAND_PASS: - konten[LEITSTAND_USER] = LEITSTAND_PASS - for eintrag in LEITSTAND_ZUGAENGE.split(","): - nutzer, _, passwort = eintrag.strip().partition(":") - if nutzer and passwort: - konten[nutzer] = passwort - return konten - -# 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: @@ -196,19 +162,6 @@ 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/dashboard.py b/pipe/dashboard.py index f13dd4e..bb7a370 100644 --- a/pipe/dashboard.py +++ b/pipe/dashboard.py @@ -11,11 +11,15 @@ import json import sys from pathlib import Path -from . import config, kategorien +from . import config -RANG = kategorien.DRINGLICHKEIT_RANG -FARBE = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()} -KAT_LABEL = kategorien.KATEGORIE_LABEL +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", +} def _anrufe() -> list[dict]: @@ -61,7 +65,7 @@ def baue(ausgabe: Path) -> Path: karten = "\n".join(_karte(d) for d in anrufe) doc = f""" -Anruf-Übersicht · {html.escape(kategorien.PROFIL_NAME)} +Anruf-Übersicht — Praxis Musterhausen
-

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

+

Anruf-Übersicht

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

{karten}
""" diff --git a/pipe/deck.py b/pipe/deck.py index c1b117e..394e302 100644 --- a/pipe/deck.py +++ b/pipe/deck.py @@ -18,15 +18,24 @@ import urllib.error import urllib.request from functools import lru_cache -from . import config, kategorien +from . import config _API = "/index.php/apps/deck/api/v1.1" -# Farben je Dringlichkeit - siehe pipe/kategorien.py. -DRINGLICHKEIT_FARBE = kategorien.DRINGLICHKEIT_FARBE +# Farben (6-stelliges Hex ohne #) je Dringlichkeit. +DRINGLICHKEIT_FARBE = { + "niedrig": "31CC7C", # grün + "normal": "F1DB50", # gelb + "hoch": "E0A339", # orange + "notfall": "E9322D", # rot +} # 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 +KATEGORIE_LABEL = { + "termin": "Termin", "rezept": "Rezept", "ueberweisung": "Überweisung", + "befund": "Befund", "verwaltung": "Verwaltung", "beschwerden": "Beschwerden", "notfall": "Notfall", + "rueckruf": "Rückruf", "sonstiges": "Sonstiges", +} @lru_cache(maxsize=1) diff --git a/pipe/dienste.py b/pipe/dienste.py deleted file mode 100644 index c17342f..0000000 --- a/pipe/dienste.py +++ /dev/null @@ -1,96 +0,0 @@ -"""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/kategorien.py b/pipe/kategorien.py deleted file mode 100644 index 4365a04..0000000 --- a/pipe/kategorien.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Kategorien-Schema des aktiven Branchen-Profils (profile//kategorien.json). - -Gebündelt an einer Stelle, damit categorize.py (JSON-Schema, Eskalationsziel -des Sicherheitsnetzes) und die Anzeige (dashboard.py, deck.py: Kategorie- -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. -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 - -import json -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) -def _daten() -> dict: - return json.loads(config.KATEGORIEN_DATEI.read_text(encoding="utf-8")) - - -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"). -SICHERHEITSNETZ_KATEGORIE = _daten().get("sicherheitsnetz_kategorie", "notfall") diff --git a/pipe/modellwahl.py b/pipe/modellwahl.py index d79eff2..0cef52b 100644 --- a/pipe/modellwahl.py +++ b/pipe/modellwahl.py @@ -5,104 +5,49 @@ 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" -# 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"] +# 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} 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 {e["id"] for e in optionen} else standard + return _STANDARD + return wert if wert in _NACH_ID else _STANDARD def setze(kennung: str) -> bool: - """Schreibt die Auswahl. False, wenn die ID nicht (mehr) vorgesehen ist.""" - if kennung not in {e["id"] for e in auswahl()}: + """Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist.""" + if kennung not in _NACH_ID: return False try: DATEI.parent.mkdir(parents=True, exist_ok=True) @@ -114,6 +59,5 @@ def setze(kennung: str) -> bool: def backend_und_modell() -> tuple[str, str]: """Backend + Modell-ID der aktiven Auswahl, fuer categorize.py.""" - kennung = aktuelle_id() - eintrag = next(e for e in auswahl() if e["id"] == kennung) + eintrag = _NACH_ID[aktuelle_id()] return eintrag["backend"], eintrag["modell"] diff --git a/pipe/monitor.py b/pipe/monitor.py deleted file mode 100644 index b60bcf4..0000000 --- a/pipe/monitor.py +++ /dev/null @@ -1,70 +0,0 @@ -"""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/protokoll.py b/pipe/protokoll.py index cf1450d..e04ddfe 100644 --- a/pipe/protokoll.py +++ b/pipe/protokoll.py @@ -12,7 +12,6 @@ from __future__ import annotations import json import shutil -import time from datetime import datetime from pathlib import Path @@ -80,26 +79,3 @@ 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/server.py b/pipe/server.py index e927781..e277b32 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -11,41 +11,105 @@ from __future__ import annotations import argparse import base64 import hmac -import html import json import re +import subprocess import sys import time +import urllib.error +import urllib.request from datetime import datetime -from functools import lru_cache -from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import parse_qs, unquote, urlparse +from urllib.parse import unquote, urlparse -from . import bewertung, config, dashboard, dienste, export, kategorien, modellwahl, protokoll, stapel, testzugang +from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, 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 -# 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 _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 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"] @@ -157,48 +221,11 @@ 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 "") - ergebnis = alle[-anzahl:] - _VERLAUF_CACHE["verlauf"] = (jetzt, ergebnis) - return ergebnis - - -# 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 + return alle[-anzahl:] class Handler(BaseHTTPRequestHandler): @@ -210,34 +237,9 @@ 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]] = [] - 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: + 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")) - 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", "") if not kopf.startswith("Basic "): return False @@ -246,11 +248,8 @@ class Handler(BaseHTTPRequestHandler): except Exception: return False # compare_digest: Vergleichsdauer verrät nichts über das Passwort. - erwartet = konten.get(nutzer) - if erwartet is None or not hmac.compare_digest(wort, erwartet): - return False - self._nutzer = nutzer - return True + return (hmac.compare_digest(nutzer, config.LEITSTAND_USER) + and hmac.compare_digest(wort, config.LEITSTAND_PASS)) def _anmeldung_verlangen(self) -> None: self.send_response(401) @@ -263,8 +262,6 @@ 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) @@ -278,34 +275,15 @@ class Handler(BaseHTTPRequestHandler): return pfad = unquote(urlparse(self.path).path) if pfad == "/": - self._sende(_seite().encode("utf-8"), "text/html; charset=utf-8") + self._sende(SEITE.encode("utf-8"), "text/html; charset=utf-8") elif pfad == "/api/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) + self._json(status()) 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([dict(a) for a in liste]) - self._json({"stapel": stapel.erlaubt(), "anrufe": liste}) + self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()}) elif pfad == "/api/verlauf": - eintraege = verlauf(150) - if getattr(self, "_via_token", False): - eintraege = _maskiere_verlauf([dict(e) for e in eintraege]) - self._json(eintraege) + self._json(verlauf(150)) 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: @@ -388,8 +366,7 @@ class Handler(BaseHTTPRequestHandler): if not stapel.setze(ordner, ziel): self._json({"ok": False, "fehler": f"unbekannter Stapel: {ziel}"}) return - zusatz = f" (von {self._nutzer})" if self._nutzer else "" - protokoll.schreibe("stapel", f"{ordner.name} → {ziel}{zusatz}", nutzer=self._nutzer) + protokoll.schreibe("stapel", f"{ordner.name} → {ziel}") self._json({"ok": True}) def _audio(self, rest: str) -> None: @@ -418,8 +395,6 @@ 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()) @@ -443,8 +418,6 @@ 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) @@ -453,7 +426,7 @@ class Handler(BaseHTTPRequestHandler): SEITE = r""" -Logpy:AgentOne — Anruf-Leitstand · __PROFIL_NAME__ +Logpy:AgentOne — Anruf-Leitstand
-

Logpy:AgentOne

Anruf-Leitstand · __PROFIL_NAME__
+

Logpy:AgentOne

Anruf-Leitstand
- -
Dienste
@@ -629,14 +596,6 @@ 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 - - 📞 +49 152 23062462 - ☁️ Nextcloud ☕ Unterstützen
@@ -657,8 +616,9 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight: """ -@lru_cache(maxsize=1) -def _seite() -> str: - """SEITE mit den Kategorie-Labels des aktiven Profils (pipe.kategorien). - - Fest pro Prozesslauf (Profil wechselt nur per .env + Neustart, wie die - Kategorien selbst) - deshalb einmalig gecacht statt bei jeder Anfrage neu - zusammengesetzt. - """ - seite = SEITE.replace("__KAT_JSON__", json.dumps(kategorien.KATEGORIE_LABEL, ensure_ascii=False)) - farben = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()} - seite = seite.replace("__FARBE_JSON__", json.dumps(farben, ensure_ascii=False)) - return seite.replace("__PROFIL_NAME__", html.escape(kategorien.PROFIL_NAME)) - - def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description="Live-Dashboard der Anruf-Pipe.") p.add_argument("--port", type=int, default=config.LEITSTAND_PORT) diff --git a/pipe/testzugang.py b/pipe/testzugang.py deleted file mode 100644 index 381c60b..0000000 --- a/pipe/testzugang.py +++ /dev/null @@ -1,35 +0,0 @@ -"""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 diff --git a/pipe/watch.py b/pipe/watch.py index 7fac5c0..49bca05 100644 --- a/pipe/watch.py +++ b/pipe/watch.py @@ -152,18 +152,10 @@ def main(argv: list[str] | None = None) -> int: try: while True: durchlauf(eingang) - 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).") + 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.") letzter_loeschlauf = time.monotonic() time.sleep(TAKT_S) except KeyboardInterrupt: diff --git a/profile/aufzug-notdienst/ansage.ogg b/profile/aufzug-notdienst/ansage.ogg deleted file mode 100644 index 0aa6cb4..0000000 Binary files a/profile/aufzug-notdienst/ansage.ogg and /dev/null differ diff --git a/profile/aufzug-notdienst/ansage.txt b/profile/aufzug-notdienst/ansage.txt deleted file mode 100644 index 50b7154..0000000 --- a/profile/aufzug-notdienst/ansage.txt +++ /dev/null @@ -1 +0,0 @@ -Aufzug-Notdienst, Testansage. Bitte hinterlassen Sie eine Nachricht nach dem Signal. diff --git a/profile/aufzug-notdienst/kategorien.json b/profile/aufzug-notdienst/kategorien.json deleted file mode 100644 index 4aa1d6c..0000000 --- a/profile/aufzug-notdienst/kategorien.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profil_name": "Aufzug-Notdienst", - "kategorien": ["eingeschlossen", "stoerung", "wartung", "rueckruf", "sonstiges"], - "kategorie_labels": { - "eingeschlossen": "Person eingeschlossen", - "stoerung": "Störung", - "wartung": "Wartung", - "rueckruf": "Rückruf", - "sonstiges": "Sonstiges" - }, - "sicherheitsnetz_kategorie": "eingeschlossen" -} diff --git a/profile/aufzug-notdienst/prompt.txt b/profile/aufzug-notdienst/prompt.txt deleted file mode 100644 index 65f319b..0000000 --- a/profile/aufzug-notdienst/prompt.txt +++ /dev/null @@ -1,44 +0,0 @@ -Du bist der Anruf-Assistent eines 24-Stunden-Notdienstes für Aufzugsanlagen. -Du bekommst das Transkript einer Sprachnachricht, die ein Anrufer auf den -Anrufbeantworter gesprochen hat. Deine Aufgabe: das Anliegen sachlich -erfassen und einordnen. - -WICHTIG: -- Du gibst KEINE technischen Anweisungen und versuchst nicht, das Problem - selbst zu lösen (z. B. "versuchen Sie die Tür aufzudrücken"). -- Du erfindest nichts. Fehlt eine Angabe im Transkript, setze null. -- Fasse neutral und knapp zusammen, in der dritten Person. -- Das Transkript kann fehlerhaft sein (automatische Spracherkennung). Interpretiere wohlwollend. -- Antworte AUSSCHLIESSLICH mit dem geforderten JSON-Objekt, ohne weiteren Text. - -Kategorien (Feld "kategorie", genau einer dieser Werte): -- "eingeschlossen" : akut jemand im Aufzug eingeschlossen/eingeklemmt, kann - nicht selbst raus -- "stoerung" : Aufzug ausser Betrieb, meldet Fehler, ungewöhnliche - Geräusche o. Ä. - aber NIEMAND eingeschlossen -- "wartung" : Wartungs-/Inspektionstermin vereinbaren, Rückfrage zu - Vertrag/Rechnung -- "rueckruf" : bittet um Rückruf ohne klar anderes Anliegen -- "sonstiges" : passt in keine der obigen - -Dringlichkeit (Feld "dringlichkeit"): "niedrig" | "normal" | "hoch" | "notfall" -- "notfall" nur bei akut eingeschlossener/eingeklemmter Person - jede Minute zählt. -- Ein stehender/gestörter Aufzug ohne eingeschlossene Person ist mindestens - "hoch", wenn er das einzige Fahrzeug im Gebäude ist oder Personen mit - Mobilitätseinschränkung betroffen sind - sonst "normal". -- Im Zweifel die höhere Stufe wählen: eine zu hoch eingestufte Nachricht - kostet einen Blick, eine zu niedrig eingestufte kann jemanden im Aufzug - gefangen lassen. - -Gib ein JSON-Objekt mit genau diesen Feldern zurück: -{ - "kategorie": "", - "dringlichkeit": "", - "anrufer_name": "", - "rueckrufnummer": "", - "rueckruf_gewuenscht": , - "email": "", - "sprache": "", - "anliegen_kurz": "<1-2 Sätze, neutrale Zusammenfassung auf Deutsch>", - "stichworte": [""] -} diff --git a/profile/aufzug-notdienst/sicherheitsnetz.txt b/profile/aufzug-notdienst/sicherheitsnetz.txt deleted file mode 100644 index e4cacd7..0000000 --- a/profile/aufzug-notdienst/sicherheitsnetz.txt +++ /dev/null @@ -1,16 +0,0 @@ -# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: ein Regex-Muster -# pro Zeile (Python re, IGNORECASE), leere Zeilen und "#"-Kommentare werden -# uebersprungen. Findet ein Muster im Transkript, eskaliert categorize.py -# Kategorie + Dringlichkeit hart auf "sicherheitsnetz_kategorie" aus -# kategorien.json ("eingeschlossen") - unabhaengig davon, was das Modell -# selbst einordnet. Nur nach oben, nie nach unten. -eingeschlossen -eingeklemmt -(sitzt|steckt|stecken)\s+fest -komm(e|en)\s+nicht\s+(mehr\s+)?raus -kommt?\s+nicht\s+(mehr\s+)?(raus|heraus) -t[üu]r\s+(geht|l[äa]sst\s+sich)\s+nicht\s+(auf|[öo]ffnen) -notruftaste -kabine\s+(h[äa]ngt|steht)\s+fest -seit\s+\w+\s+(stunden?|minuten?)\s+im\s+aufzug -person\s+im\s+aufzug diff --git a/profile/praxis/kategorien.json b/profile/praxis/kategorien.json deleted file mode 100644 index 887f6e0..0000000 --- a/profile/praxis/kategorien.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "profil_name": "Hausarztpraxis", - "kategorien": [ - "termin", "rezept", "ueberweisung", "befund", - "verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges" - ], - "kategorie_labels": { - "termin": "Termin", - "rezept": "Rezept", - "ueberweisung": "Überweisung", - "befund": "Befund", - "verwaltung": "Verwaltung", - "beschwerden": "Beschwerden", - "notfall": "Notfall", - "rueckruf": "Rückruf", - "sonstiges": "Sonstiges" - }, - "sicherheitsnetz_kategorie": "notfall" -} diff --git a/profile/praxis/sicherheitsnetz.txt b/profile/praxis/sicherheitsnetz.txt deleted file mode 100644 index ad35025..0000000 --- a/profile/praxis/sicherheitsnetz.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: ein Regex-Muster -# pro Zeile (Python re, IGNORECASE), leere Zeilen und "#"-Kommentare werden -# uebersprungen. Findet ein Muster im Transkript, eskaliert categorize.py -# Kategorie + Dringlichkeit hart auf "sicherheitsnetz_kategorie" aus -# kategorien.json ("notfall") - unabhaengig davon, was das Modell selbst -# einordnet. Nur nach oben, nie nach unten: ein Fehlalarm kostet einen Blick, -# ein uebersehener Notfall kann einen Patienten kosten. -# -# Grund fuer dieses Netz: 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. -atemnot -(kriege|bekomme|krieg)\w*\s+(kaum|keine|schwer)\s+luft -kann\s+(kaum|nicht)\s+(mehr\s+)?atmen -brust\w*schmerz -schmerzen?\s+in\s+der\s+brust -bewusstlos -ohnm[aä]chtig -nicht\s+ansprechbar -blutet\s+(stark|heftig|sehr) -starke?\s+blutung diff --git a/profile/praxis/prompt.txt b/prompts/categorize_de.txt similarity index 100% rename from profile/praxis/prompt.txt rename to prompts/categorize_de.txt diff --git a/requirements.txt b/requirements.txt index 3e805da..ff5a861 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,3 @@ piper-tts>=1.2 certifi # Nur nötig für STT_BACKEND=faster; der Standard ist whisper.cpp. faster-whisper>=1.2 - -# Outbound-ESL-Bootstrap fuer die Pipecat-Testleitung (dialog/pipecat_bootstrap.py): -# nimmt FreeSWITCHs socket()-Uebergabe an, macht answer()+uuid_audio_stream+park(). -greenswitch>=0.0.19 diff --git a/profile/praxis/ansage.txt b/telefon/ansage.txt similarity index 100% rename from profile/praxis/ansage.txt rename to telefon/ansage.txt diff --git a/telefon/ansage_bauen.sh b/telefon/ansage_bauen.sh index 93033b6..a2c2572 100755 --- a/telefon/ansage_bauen.sh +++ b/telefon/ansage_bauen.sh @@ -1,20 +1,16 @@ #!/bin/bash -# Erzeugt die Telefonansage aus profile/$PROFIL/ansage.txt (Branchen-Profil, -# siehe README). +# Erzeugt die Telefonansage aus telefon/ansage.txt. # Ergebnis: telefon/ansage.wav in Telefonqualitaet (8 kHz mono, PCM16). # -# Vier Quellen: +# Drei Sprachsynthesen: # piper (Standard, natuerlicher, lokal) - Modelle unter ~/piper-voices # say (macOS-Bordmittel, lokal, Rueckfall wenn Piper fehlt) # mistral (Voxtral-API, Cloud - nur fuer diesen einmaligen Erzeugungslauf; # die fertige ansage.wav wird danach lokal fuer jeden Anruf # wiederverwendet, es geht dabei keine Patientendaten raus) -# datei (echte Aufnahme statt Synthese - profile/$PROFIL/ansage., -# ansage.txt wird dabei nicht gelesen) # # Steuerung ueber Umgebungsvariablen bzw. .env: -# PROFIL= -# ANSAGE_TTS=piper|say|mistral|datei +# ANSAGE_TTS=piper|say|mistral # ANSAGE_STIMME= # ANSAGE_EMOTION=&2; exit 1; } - STIMMEN_ORDNER="${PIPER_STIMMEN:-$HOME/piper-voices}" STIMME="${ANSAGE_STIMME:-de_DE-thorsten_emotional-medium}" EMOTION="${ANSAGE_EMOTION:-0}" @@ -58,28 +50,10 @@ fi TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT case "$TTS" in - datei) - # Echte Aufnahme statt Sprachsynthese - profile/$PROFIL/ansage. - # (erste gefundene Endung gewinnt). ansage.txt wird in diesem Modus nicht - # gelesen; sie bleibt trotzdem als Beleg/Backup stehen, was gesagt wird. - QUELLE="" - for ext in ogg mp3 wav m4a aiff; do - if [ -f "profile/$PROFIL/ansage.$ext" ]; then - QUELLE="profile/$PROFIL/ansage.$ext" - break - fi - done - [ -n "$QUELLE" ] || { - echo "Keine Ansage-Audiodatei gefunden (profile/$PROFIL/ansage.{ogg,mp3,wav,m4a,aiff})" >&2 - exit 1 - } - ffmpeg -v error -y -i "$QUELLE" -ar 8000 -ac 1 "$TMP/sprache.wav" - BESCHREIBUNG="datei/$QUELLE" - ;; piper) MODELL="$STIMMEN_ORDNER/$STIMME.onnx" [ -f "$MODELL" ] || { echo "Piper-Stimme fehlt: $MODELL" >&2; exit 1; } - tr '\n' ' ' < "$ANSAGE_TXT" \ + tr '\n' ' ' < telefon/ansage.txt \ | python3 -m piper -m "$MODELL" -s "$EMOTION" --length-scale "$TEMPO" \ -f "$TMP/sprache_roh.wav" ffmpeg -v error -y -i "$TMP/sprache_roh.wav" -ar 8000 -ac 1 "$TMP/sprache.wav" @@ -87,7 +61,7 @@ case "$TTS" in ;; say) SAY_STIMME="${ANSAGE_STIMME:-Anna}" - say -v "$SAY_STIMME" -r 180 -f "$ANSAGE_TXT" -o "$TMP/sprache.aiff" + say -v "$SAY_STIMME" -r 180 -f telefon/ansage.txt -o "$TMP/sprache.aiff" ffmpeg -v error -y -i "$TMP/sprache.aiff" -ar 8000 -ac 1 "$TMP/sprache.wav" BESCHREIBUNG="say/$SAY_STIMME" ;; @@ -95,7 +69,7 @@ case "$TTS" in MISTRAL_STIMME="${ANSAGE_STIMME:-en_paul_neutral}" MISTRAL_MODELL="${MISTRAL_TTS_MODEL:-voxtral-mini-tts-latest}" [ -n "${MISTRAL_KEY:-}" ] || { echo "MISTRAL_KEY fehlt (ANSAGE_TTS=mistral)" >&2; exit 1; } - TEXT="$(tr '\n' ' ' < "$ANSAGE_TXT")" + TEXT="$(tr '\n' ' ' < telefon/ansage.txt)" PAYLOAD="$(TTS_MODEL="$MISTRAL_MODELL" TTS_VOICE="$MISTRAL_STIMME" python3 -c ' import json, os, sys print(json.dumps({ diff --git a/telefon/freeswitch/dw3_warten.xml.tpl b/telefon/freeswitch/dw3_warten.xml.tpl deleted file mode 100644 index f3b1471..0000000 --- a/telefon/freeswitch/dw3_warten.xml.tpl +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/telefon/freeswitch/dw9_rufagent.xml.tpl b/telefon/freeswitch/dw9_rufagent.xml.tpl deleted file mode 100644 index 7d10bf3..0000000 --- a/telefon/freeswitch/dw9_rufagent.xml.tpl +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/telefon/freeswitch/einrichten.sh b/telefon/freeswitch/einrichten.sh index e8bb4af..4da1b8c 100755 --- a/telefon/freeswitch/einrichten.sh +++ b/telefon/freeswitch/einrichten.sh @@ -44,37 +44,14 @@ fuelle() { # -e "s|@@SIP_PASS@@|$SIP_PASS|g" \ -e "s|@@SIP_DOMAIN@@|$SIP_DOMAIN|g" \ -e "s|@@SIP_PROXY@@|${SIP_PROXY:-$SIP_DOMAIN}|g" \ - -e "s|@@DW3_DDI@@|${DW3_DDI:-}|g" \ - -e "s|@@DW9_DDI@@|${DW9_DDI:-}|g" \ -e "s|@@PROJEKT@@|$PROJEKT|g" "$1" > "$2" } fuelle "$PROJEKT/telefon/freeswitch/plusnet.xml.tpl" "$FS_ETC/sip_profiles/external/plusnet.xml" chmod 600 "$FS_ETC/sip_profiles/external/plusnet.xml" # enthaelt das SIP-Passwort fuelle "$PROJEKT/telefon/freeswitch/praxis_ab.xml.tpl" "$FS_ETC/dialplan/public/00_praxis_ab.xml" -# Kontext fuer Taste 9 waehrend der Ansage - Top-Level unter dialplan/, -# nicht in public/ (siehe hauptnummer_menue.xml.tpl). -fuelle "$PROJEKT/telefon/freeswitch/hauptnummer_menue.xml.tpl" "$FS_ETC/dialplan/hauptnummer_menue.xml" echo "Trunk -> $FS_ETC/sip_profiles/external/plusnet.xml (Rechte 600)" echo "Dialplan -> $FS_ETC/dialplan/public/00_praxis_ab.xml" echo "Ansage -> $PROJEKT/telefon/ansage.wav" -echo "Menue -> $FS_ETC/dialplan/hauptnummer_menue.xml (Taste 9 -> Astra)" echo "Eingang -> $PROJEKT/telefon/eingang" - -if [ -n "${DW3_DDI:-}" ]; then - fuelle "$PROJEKT/telefon/freeswitch/dw3_warten.xml.tpl" "$FS_ETC/dialplan/public/00_dw3_warten.xml" - echo "DW3 -> $FS_ETC/dialplan/public/00_dw3_warten.xml (DDI: $DW3_DDI, 20s Wartezeit)" -else - echo "DW3 -> uebersprungen (DW3_DDI leer in .env)" -fi - -# DW9 (rufagent) -> Astra-Telefonagent. Dateiname "00_dw9_..." ist Absicht: -# muss alphabetisch vor 00_praxis_ab.xml einsortieren, siehe -# dw9_rufagent.xml.tpl fuer die Begruendung (2026-09-13 gefunden). -if [ -n "${DW9_DDI:-}" ]; then - fuelle "$PROJEKT/telefon/freeswitch/dw9_rufagent.xml.tpl" "$FS_ETC/dialplan/public/00_dw9_rufagent.xml" - echo "DW9 -> $FS_ETC/dialplan/public/00_dw9_rufagent.xml (DDI: $DW9_DDI, -> Astra)" -else - echo "DW9 -> uebersprungen (DW9_DDI leer in .env)" -fi diff --git a/telefon/freeswitch/hauptnummer_menue.xml.tpl b/telefon/freeswitch/hauptnummer_menue.xml.tpl deleted file mode 100644 index 83eb053..0000000 --- a/telefon/freeswitch/hauptnummer_menue.xml.tpl +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - diff --git a/telefon/freeswitch/praxis_ab.xml.tpl b/telefon/freeswitch/praxis_ab.xml.tpl index 87a2480..445f142 100644 --- a/telefon/freeswitch/praxis_ab.xml.tpl +++ b/telefon/freeswitch/praxis_ab.xml.tpl @@ -9,14 +9,7 @@ - - - + diff --git a/telefon/starten.sh b/telefon/starten.sh index 08feb73..5bc2b82 100755 --- a/telefon/starten.sh +++ b/telefon/starten.sh @@ -18,33 +18,6 @@ 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 - -# Stoerungswache als Erstes - sie muss gerade dann laufen, wenn der Rest nicht -# hochkommt (Vorabpruefung schlaegt fehl, FreeSWITCH stirbt, Trunk registriert -# nicht). Frueher startete sie erst nach Trunk UP und fehlte damit genau bei -# Ausfaellen: 2026-09-14 war die Leitung nach einem Neustart ~80 min tot, ohne -# Alarm. Sie meldet FreeSWITCH/Trunk selbst (pipe.dienste), beim normalen -# Hochfahren hoechstens einmal "gestoert" und kurz darauf "wieder ok". -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 - fehler=0 melde() { printf ' %-38s %s\n' "$1" "$2"; } @@ -92,14 +65,7 @@ else # -rp: Echtzeit-Priorität. Auf einem Rechner, der nebenher Whisper und ein # Sprachmodell laufen lässt, kommt FreeSWITCH sonst nicht rechtzeitig dran und # das Audio stückelt - RTP will alle 20 ms ein Paket losschicken. - # -nonat: keine NAT-PMP/UPnP-Erkennung am Router (192.168.2.1:5351). Unter - # launchd (Watchdog) blockiert macOS' "Lokales Netzwerk"-Datenschutz jedes - # Senden ins LAN mit EPIPE; so frueh im Start ignoriert FreeSWITCH SIGPIPE - # noch nicht und stirbt 0,5 s nach dem Backgrounding - ohne Log, ohne - # Crash-Report. Aus einer Terminal-Shell passiert das nicht, deshalb lief es - # manuell. Der Router antwortet ohnehin nicht (nat_map status: UNKNOWN); die - # externe IP kommt per STUN (vars.xml), Trunks und Sockets brauchen kein LAN. - freeswitch -nc -rp -nonat -nonatmap \ + freeswitch -nc -rp -nonatmap \ -conf "$FS_ETC" \ -log /opt/homebrew/var/log/freeswitch \ -db /opt/homebrew/var/lib/freeswitch/db \ @@ -108,30 +74,6 @@ else sleep 15 fi -# Oeffentliche IP abgleichen. FreeSWITCH holt sie per STUN nur beim Laden der -# XML (vars.xml, stun-set) und meldet sie Plusnet als Contact. Nach einer -# Zwangstrennung des Routers mit neuer IP bleibt der Trunk trotzdem REGED/UP -# (Registrierung und Pings gehen raus), aber eingehende INVITEs laufen an die -# alte Adresse - die ganze Leitung ist stumm, ohne Fehler im Log. So am -# 2026-09-17 passiert: Contact 9.246.125.72, tatsaechlich 217.142.18.120. -# Bei Abweichung: reloadxml (STUN neu) + external-Profil neu starten, nur wenn -# gerade kein Gespraech laeuft - sonst im naechsten Watchdog-Lauf. -aktuell=$(fs_cli -P "$FS_PORT" -x "stun stun.freeswitch.org" 2>/dev/null | head -1 | cut -d: -f1) -gemeldet=$(fs_cli -P "$FS_PORT" -x "sofia status profile external" 2>/dev/null \ - | awk '/^Ext-SIP-IP/{print $2}') -if [[ "$aktuell" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && [ -n "$gemeldet" ] \ - && [ "$aktuell" != "$gemeldet" ]; then - gespraeche=$(fs_cli -P "$FS_PORT" -x "show calls count" 2>/dev/null | awk '/total/{print $1}') - if [ "${gespraeche:-0}" = "0" ]; then - echo "$(date '+%F %T') Oeffentliche IP geaendert ($gemeldet -> $aktuell) - external-Profil wird neu gestartet." - fs_cli -P "$FS_PORT" -x "reloadxml" >/dev/null 2>&1 - fs_cli -P "$FS_PORT" -x "sofia profile external restart" >/dev/null 2>&1 - sleep 10 - else - echo "$(date '+%F %T') Oeffentliche IP geaendert ($gemeldet -> $aktuell), aber $gespraeche Gespraech(e) aktiv - naechster Lauf." - fi -fi - # Auf die Registrierung beim Provider warten. # Quelle ist fs_cli, nicht das Log: das Log wird zum Testen geleert und taugt # dann nicht mehr als Zustandsquelle. @@ -157,27 +99,15 @@ 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 - tail -1 telefon/leitstand.log - fi - # Pipecat-Testleitung (Nebenstelle 7501) - nur wenn konfiguriert, siehe - # dialog/pipecat_bootstrap.py und dialplan/default/06_agentzwei_pipecat_test.xml. - if [ -n "${ASTRA_TELEFON_WS_URL:-}" ]; then - if pgrep -f "[d]ialog.pipecat_bootstrap" >/dev/null; then - echo "Pipecat-Bootstrap laeuft bereits." - else - # Aus der Projekt-venv: greenswitch/gevent fehlen im System-python3, - # dort bricht der Bootstrap sofort mit ModuleNotFoundError ab. - nohup .venv/bin/python -u -m dialog.pipecat_bootstrap >> telefon/pipecat_bootstrap.log 2>&1 & - echo "Pipecat-Bootstrap gestartet (Log: telefon/pipecat_bootstrap.log)" - fi + head -1 telefon/leitstand.log fi sleep 2 echo