Compare commits

..

39 commits
v1.0.0 ... main

Author SHA1 Message Date
Jeuner
9c4903043e fix(telefon): FreeSWITCH-Profil nach Wechsel der oeffentlichen IP neu starten
FreeSWITCH meldete Plusnet nach einer Zwangstrennung weiter die alte IP
als Contact - Trunk blieb REGED, eingehende Anrufe kamen nie an.
starten.sh gleicht jetzt bei jedem Watchdog-Lauf STUN-IP und Ext-SIP-IP ab.
HANDOFF.md dokumentiert zusaetzlich AbandonProcessGroup im launchd-plist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:33:08 +02:00
Jeuner
f42ac7ded5 docs: record the launchd restart outage and its three fixes in HANDOFF.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011csZF3rmXJSAtukDxUMFgy
2026-09-14 15:55:08 +02:00
Jeuner
6db89affd4 fix(telefon): Stoerungswache vor allen anderen Pruefungen starten
starten.sh startete pipe.monitor erst nach erfolgreicher Trunk-Registrierung.
Kam FreeSWITCH nicht hoch, lief damit auch die Wache nicht - 2026-09-14 war
die Leitung nach einem Neustart ~80 min tot, ohne einen einzigen Alarm.
Jetzt startet sie direkt nach der Log-Rotation, noch vor der Vorabpruefung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011csZF3rmXJSAtukDxUMFgy
2026-09-14 15:54:34 +02:00
Jeuner
f785ab3bdb fix(telefon): FreeSWITCH mit -nonat starten, damit der launchd-Watchdog es hochbekommt
Unter launchd blockiert macOS' Local-Network-Privacy das Senden ins LAN
(send() liefert EPIPE, UserEventAgent: "Got local network blocked
notification"). FreeSWITCH schickt in switch_core_init eine NAT-PMP-Anfrage
an den Router (192.168.2.1:5351), bevor es SIGPIPE ignoriert, und stirbt
daran ~0,5 s nach dem Backgrounding (mit -nf: exit 141). Aus der Shell
greift die Sperre nicht, deshalb lief der manuelle Start.

Reproduziert mit einer Testinstanz (eigene Ports, ohne Gateways) unter
einem temporaeren LaunchAgent: ohne -nonat stirbt sie nach ~2 s, mit
-nonat laeuft sie (status UP, Ext-RTP-IP per STUN wie live). Die
NAT-Erkennung fand live ohnehin nichts (nat_map status: UNKNOWN).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011csZF3rmXJSAtukDxUMFgy
2026-09-14 14:18:37 +02:00
Jeuner
907f511c0d fix(telefon): Pipecat-Bootstrap aus der Projekt-venv starten
starten.sh rief dialog.pipecat_bootstrap mit System-python3 auf, dem
gevent/greenswitch fehlen - der Bootstrap brach nach dem Neustart des
Mac mini sofort mit ModuleNotFoundError ab, und der launchd-Watchdog
konnte ihn nicht wiederbeleben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011csZF3rmXJSAtukDxUMFgy
2026-09-14 14:02:09 +02:00
Jeuner
3f9cfe6b4f docs: record the dead-air-after-empty-transcript fix
Cross-reference to martin-voice-interface commit c8946d0.
2026-09-13 13:46:44 +02:00
Jeuner
b24843ebe3 docs: record the max-tokens cap and flush_now fix
Cross-reference to martin-voice-interface commits 4e6f266 and 42acca4.
2026-09-13 13:32:14 +02:00
Jeuner
a797075dde docs: record the TTSStoppedFrame dead-code bug and idle-flush fix
Cross-reference to martin-voice-interface commit 0a0c8d5, which fixed the
silent-greeting regression the previous cap fix introduced.
2026-09-13 13:07:57 +02:00
Jeuner
7f5f9785d5 docs: record the Ollama cold-load bug and its fix in HANDOFF.md
Cross-reference to martin-voice-interface commit a66629a, which fixed the
"agent says 'Einen Moment bitte' but never answers" symptom this file
flagged as the open blocker.
2026-09-13 12:25:50 +02:00
Jeuner
a0f1beb662 feat: route Durchwahl 9 (rufagent) to Astra via dedicated dialplan rule
Calls to DW9 were falling through to the praxis answering machine because
no dialplan/public rule matched its DDI header, and dialplan/agentzwei's
existing rule lives in a context real inbound calls never reach. Add
dw9_rufagent.xml.tpl (deployed as 00_dw9_rufagent.xml, must sort before
00_praxis_ab.xml alphabetically, see comment) and wire it into
einrichten.sh. Update HANDOFF.md with this session's findings.
2026-09-13 12:19:55 +02:00
Jeuner
394a0faa27 feat(telefon): Outbound-ESL-Bruecke fuer Live-Pipecat-Gespraeche
Bruecke zwischen FreeSWITCH/mod_audio_stream und dem neuen Astra-Telefonie-
Endpunkt (/telefon/inbound) - erlaubt echte, live gefuehrte Gespraeche
statt nur Aufnahme+Nachbearbeitung:

- dialog/pipecat_bootstrap.py: nimmt Kanaele per socket() entgegen
  (Nebenstelle 7501 intern, oder von dialog/anrufen.py ausgehend),
  startet uuid_audio_stream und haengt fuer die gesamte Gespraechsdauer
  auf der Leitung, um mod_audio_stream::play-Events abzuspielen
  (playback() - mod_audio_stream schreibt nur eine Datei und feuert ein
  Event, spielt selbst nichts ab). uuid_audio_stream laeuft bewusst ueber
  einen separaten fs_cli-Prozess, nicht ueber die eigene ESL-Verbindung
  (siehe Kommentar im Modul - sonst schlaegt der WebSocket-Connect bei
  echten externen Anrufen zuverlaessig fehl, amigniter/mod_audio_stream#92).
- dialog/anrufen.py: loest ausgehende Anrufe aus (python3 -m dialog.anrufen
  <Nummer>), haengt sie an dieselbe Bruecke.
- .env.example, requirements.txt (greenswitch), telefon/starten.sh: Bootstrap
  als vierten Hintergrunddienst mitstarten, wenn ASTRA_TELEFON_WS_URL gesetzt
  ist.

mod_audio_stream selbst (gebaut mit -DUSE_TLS=ON) und die FreeSWITCH-Gateway-
Konfiguration (Domain/Proxy-Trennung fuer Outbound, siehe agentzwei_sbc.xml)
liegen direkt in der FreeSWITCH-Konfiguration, nicht in diesem Repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfxZnUC9dZ8QT36pkfEPmM
2026-09-13 00:56:54 +02:00
Jeuner
3da0cd7ec9 feat(telefon): Wartezeit vor answer() fuer parallel klingelnde Durchwahl (DW3)
Manche Provider (Fonial/Plusnet) klingeln bei einer Durchwahl parallel an
mehreren registrierten Zielen. Ohne Verzoegerung gewinnt AgentOne das Rennen
immer, weil answer() sofort laeuft - das eigentliche Endgeraet kommt nie zum
Klingeln. DW3_DDI in der .env schaltet 20s Wartezeit vor answer() frei,
erkannt ueber den SIP-Header X-ORIGINAL-DDI-URI (destination_number ist bei
diesem Trunk unbrauchbar). Nimmt jemand am Endgeraet ab, bricht der Provider
AgentOnes Versuch per CANCEL ab; sonst laeuft der Dialplan normal weiter zu
Ansage+Aufnahme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ovr91y2VK8bYYKPimcX68
2026-09-01 02:53:05 +02:00
Jeuner
b3d5e82958 docs: MITGABE.md entfernt (veraltet)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHyAcP2JsKWKN9wbcwVjB9
2026-08-31 08:47:27 +02:00
Jeuner
4d46d846ea docs: veralteten MITGABE.md-Hinweis aus README entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHyAcP2JsKWKN9wbcwVjB9
2026-08-31 08:46:28 +02:00
Jeuner
069b5514d5 docs: Screenshot aktualisiert (agentone-screen-1 -> logpy-agentone-1-1)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHyAcP2JsKWKN9wbcwVjB9
2026-08-31 08:44:17 +02:00
Jeuner
17257e00c4 release: v1.1.0
Branchen-Profile, mehrere Leitstand-Zugaenge und Testzugang-Haertung
seit v1.0.0 - siehe CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EQpbviCVb3wQ76fap9Fj
2026-08-30 14:07:23 +02:00
Jeuner
bdb5bd510a docs: README-Einleitung an Branchen-Profil-Umbau anpassen
"Praxis-Telefon-Agent... Hausarztpraxis" stimmte nicht mehr - das Projekt ist
seit dem Branchen-Profil-Umbau universell konfigurierbar (praxis,
aufzug-notdienst als Beispiele). Einleitung + "Vollstaendig on-prem"-Zeile
generalisiert, Link auf den Branchen-Profil-Abschnitt ergaenzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 19:28:30 +02:00
Jeuner
5c9f891c7e perf: /api/anrufe und /api/verlauf cachen, Dringlichkeit-Farben zentralisiert
Audit auf Optimierungspotenzial ergab:
- /api/anrufe las bei jeder Anfrage ALLE meta.json unter ablage/ neu (auch
  bereits archivierte, erst danach rausgefiltert) - waechst mit der
  Gesamtzahl je empfangener Anrufe, jetzt von bis zu 3 Logins alle 3s
  parallel gepollt.
- /api/verlauf las bei jeder Anfrage bis zu 300 KB aus dem FreeSWITCH-Log
  und scannte 4000 Zeilen per Regex, ebenfalls ungecacht.
Beide bekommen jetzt einen kurzen 2s-Cache (gleiches Muster wie
dienste.status()). Maskierung bei Testzugang arbeitet jetzt auf einer Kopie
(dict(a) je Eintrag) statt die gecachten Objekte in-place zu veraendern -
sonst haetten maskierte Rufnummern in die naechste, nicht maskierte Antwort
durchsickern koennen.

Dringlichkeit-Farben (rot/orange/gruen/grau) und der Sortierrang standen in
drei Dateien dupliziert (dashboard.py, deck.py, server.py-JS) - bei der
letzten Farbaenderung mussten alle drei von Hand synchron gehalten werden.
Jetzt eine Quelle in pipe/kategorien.py (DRINGLICHKEIT_FARBE,
DRINGLICHKEIT_RANG - Rang aus DRINGLICHKEITEN abgeleitet statt separat
gepflegt), server.py injiziert die Farben serverseitig wie schon die
Kategorie-Labels.

Getestet: RANG/FARBE identisch zum alten Stand, /api/anrufe und /api/verlauf
liefern bei zwei Anfragen innerhalb 2s dasselbe (gecachte) Ergebnis, Maskierung
veraendert nachweislich nur die Kopie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 19:14:21 +02:00
Jeuner
ab2bd949be feat: Log-Rotation fuer Rohlogs + Aufraeumen alter Verlauf-Archivkopien
Audit ergab: "Tag archivieren" sichert/leert nur verlauf.log. Die rohen
stdout/stderr-Logs (telefon/autostart.log vom launchd-Watchdog
net.dillenberg.fonagent, alle 5 Min; watcher.log/leitstand.log/monitor.log)
wachsen unbegrenzt - autostart.log stand bei 523 KB/16595 Zeilen ohne jede
Bereinigung. audit.log ist bewusst permanent (Compliance, unveraendert).

starten.sh kuerzt diese vier Dateien jetzt ab 2 MB auf die letzten 2000
Zeilen (in-place, gleiches Inode) - laeuft ohnehin alle 5 Min per Watchdog.
Redirects von > auf >> umgestellt (O_APPEND), damit die In-Place-Kuerzung
fuer die noch laufenden Prozesse sicher ist (kein Datenverlust durch
gecachte Schreib-Offsets).

pipe.protokoll.raeume_archiv_auf() loescht verlauf-archiv/*.log aelter als
90 Tage - lief bisher unbegrenzt an. Eingehaengt in pipe.watch's ohnehin
taeglichen Check (bisher nur fuer LOESCHFRIST_TAGE), jetzt unabhaengig davon.

Getestet: Kuerz-Logik (3,8 MB -> 2000 Zeilen) und raeume_archiv_auf()
(gezielt gealterte Testdatei geloescht, Rest unangetastet) isoliert
verifiziert, echte Dateien nicht angefasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 19:07:31 +02:00
Jeuner
be11baf52b feat: ?logoff in der URL loest die Abmeldung zusaetzlich zum Button aus
Der Abmelden-Button blendet sich erst nach dem ersten erfolgreichen
Status-Abruf ein - bei zugestopftem/schmalem Header oder einer alten, nicht
neu geladenen Seite war er dadurch schwer zu finden. ".../?logoff" an die
Leitstand-URL angehaengt loest denselben Ablauf jetzt garantiert aus,
unabhaengig vom Button. Query-String wird danach aus der URL entfernt,
damit ein Reload nicht erneut abmeldet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:59:24 +02:00
Jeuner
37a5a78b15 feat: Abmelden-Button im Leitstand
HTTP Basic Auth kennt keine Server-Session zum Beenden - der Browser cached
die Zugangsdaten selbst. Der neue Button loescht das Testzugang-Cookie und
schickt einen Request mit falschen Zugangsdaten (per XHR mit explizitem
user/password), was den Basic-Auth-Cache der gaengigen Browser ueberschreibt;
ein Reload danach zeigt wieder den Anmelde-Dialog. Nur sichtbar, wenn
ueberhaupt ein Zugangsschutz aktiv ist (s.nutzer gesetzt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:41:51 +02:00
Jeuner
184fb37593 feat: eingeloggter Nutzer im Leitstand-Header sichtbar
Mit mehreren Leitstand-Konten (praxis/notdienst/admin) war im UI selbst
nicht erkennbar, als wer man gerade angemeldet ist. /api/status liefert
jetzt "nutzer" (aus self._nutzer, siehe voriger Commit), das Frontend zeigt
ihn im Header neben dem Stand-Zeitstempel ("👤 notdienst").

Nebenbei behoben: dienste.status() gibt ein gecachtes, ueber Requests
geteiltes dict zurueck - der Handler mutierte es bisher direkt
(testzugang-Feld). Bei gleichzeitigen Anfragen verschiedener Nutzer haette
das den Nutzernamen der einen Anfrage in die Antwort einer anderen
durchsickern lassen koennen. Jetzt eine flache Kopie vor dem Mutieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:39:26 +02:00
Jeuner
b1cb3471a5 feat: Verlauf zeigt jetzt, wer eine Karte verschoben hat
_angemeldet() merkt sich jetzt den eingeloggten Nutzernamen (self._nutzer) -
bei mehreren Leitstand-Konten (LEITSTAND_ZUGAENGE) war bisher nicht
nachvollziehbar, wer eine Karte zwischen den Stapeln verschoben hat. Der
Verlauf-Eintrag zeigt "(von <nutzer>)" in derselben Zeile, "nutzer" steht
zusaetzlich als eigenes Feld im JSONL (Verlauf + Audit-Log).

Getestet: Verschieben per Testanruf + notdienst-Login, Eintrag im Verlauf
verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:32:10 +02:00
Jeuner
8621691054 feat: aktives Branchen-Profil im Leitstand/Dashboard sichtbar machen
Welches Profil (PROFIL) gerade aktiv ist, stand bisher nur in der .env - im
laufenden Leitstand war es unsichtbar, obwohl Kategorien und Ansage sich
je nach Profil komplett unterscheiden (Praxis vs. Aufzug-Notdienst).

kategorien.json bekommt ein "profil_name"-Feld (Anzeigename), neues
kategorien.PROFIL_NAME laedt es. Browsertab-Titel und Header-Untertitel im
Leitstand sowie Titel/Ueberschrift im Dashboard-Export zeigen es jetzt an.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:24:28 +02:00
Jeuner
8e54a58f13 ui: Dringlichkeitsfarben getauscht - normal gruen, niedrig grau statt gelb/gruen
Gelb fuer "normal" wirkte zu warnend fuer einen unauffaelligen Regelfall.
normal ist jetzt gruen, niedrig (unwichtig) grau - hoch (orange) und notfall
(rot) unveraendert. Betrifft Leitstand, Dashboard und Deck-Labels.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:20:36 +02:00
Jeuner
07010fbfdf feat: echte Aufnahme statt Sprachsynthese als Ansage (ANSAGE_TTS=datei)
ansage_bauen.sh unterstuetzt jetzt eine vierte Quelle neben piper/say/mistral:
"datei" nimmt eine vorhandene Aufnahme (profile/$PROFIL/ansage.<ext>) statt
sie aus ansage.txt zu synthetisieren, durchlaeuft aber dieselbe Aufbereitung
(8kHz mono, Signalton, -3dB Headroom) wie die TTS-Pfade.

profile/aufzug-notdienst/ansage.ogg ist die reale Aufnahme fuer das
Notdienst-Beispielprofil (Pegel geprueft: Peak ~0dB vor, ~-3dB nach der
Verarbeitung - kein Clipping).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:16:57 +02:00
Jeuner
795dcf7eeb feat: mehrere Leitstand-Zugaenge gleichzeitig (LEITSTAND_ZUGAENGE)
Bisher gab es genau ein Nutzer/Passwort-Paar (LEITSTAND_USER/LEITSTAND_PASS).
Fuer einen zweiten Einsatzzweck/ein zweites Team am selben Leitstand (z.B.
Notdienst neben Praxis) reicht das nicht. LEITSTAND_ZUGAENGE in der .env
nimmt jetzt zusaetzliche "nutzer:passwort"-Paare (kommagetrennt) an - das
bestehende LEITSTAND_USER/LEITSTAND_PASS bleibt als erstes Konto gueltig,
config.leitstand_konten() buendelt alle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 18:10:31 +02:00
Jeuner
87621e5bec feat: Branchen-Profile - Projekt fuer andere Einsatzzwecke konfigurierbar machen
Kategorien, Prompt und Notfall-Sicherheitsnetz waren fest auf eine
Hausarztpraxis zugeschnitten (categorize.py: KATEGORIEN-Liste, _NOTFALL_MUSTER-
Regex). Fuer einen anderen Einsatzzweck (z.B. 24h-Aufzug-Notdienst) musste
bisher Python-Code geaendert werden.

Neu: profile/<name>/ buendelt prompt.txt, kategorien.json, sicherheitsnetz.txt
und ansage.txt - alles, was fachlich vom Einsatzzweck abhaengt, an einer
Stelle. PROFIL in der .env waehlt den aktiven Ordner (Default "praxis", altes
Verhalten bleibt exakt gleich - per Test verifiziert: identische Kategorien-
Liste und Sicherheitsnetz-Muster). profile/aufzug-notdienst/ liegt als
ausformuliertes Beispielprofil bei (eigene Kategorien, eigenes
Sicherheitsnetz, eigene Ansage).

Dringlichkeitsstufen (niedrig/normal/hoch/notfall) bleiben bewusst fest -
nur die Kategorien sind einsatzzweck-spezifisch. Neues pipe/kategorien.py
laedt das Profil-Schema einmal fuer categorize.py, dashboard.py, deck.py und
server.py (Leitstand-JS: Kategorie-Label kommen jetzt vom Server statt fest
im Frontend zu stehen).

prompts/categorize_de.txt und telefon/ansage.txt sind nach profile/praxis/
umgezogen (git erkennt es als Rename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 17:07:29 +02:00
Jeuner
d39b360b7b feat: Modell-Dropdown listet lokale Ollama-Modelle live statt fest im Code
Bisher stand nur config.OLLAMA_MODELL zur Auswahl - ein neu per "ollama pull"
installiertes Modell (z.B. ornith-1.5:9b) tauchte nicht im Leitstand auf, ohne
Code-Aenderung. modellwahl.auswahl() fragt jetzt GET /api/tags live ab und
baut die Ollama-Optionen daraus; reine Embedding-Modelle (capabilities ohne
"completion", z.B. nomic-embed-text) werden ausgefiltert, weil sie nicht
kategorisieren koennen. Die festen OpenRouter-Testeintraege bleiben bestehen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 16:53:45 +02:00
Jeuner
2605710818 merge: Linux-Setup-Hinweis (origin) mit Störungswache/FileVault-Doku zusammenführen 2026-08-28 16:48:00 +02:00
Jeuner
6e5fe59c53 feat: Störungswache (pipe.monitor) + Verschlüsselung-at-rest dokumentiert
Dienste-Ampel (Telefon, Verarbeitung, Spracherkennung, Nextcloud) alarmierte
bisher nur im offenen Leitstand. pipe/monitor.py laeuft jetzt als eigener
Dauerprozess, prueft dieselbe Ampel (neu ausgelagert nach pipe/dienste.py)
periodisch und alarmiert bei Stoerung lokal per macOS-Benachrichtigung + Ton
(pipe/alarm.py) - unabhaengig davon, ob pipe.watch oder pipe.server laufen.
Wird ab jetzt von telefon/starten.sh mitgestartet.

Verschluesselung at rest: keine App-eigene Verschluesselung eingebaut,
stattdessen im README als Betreiber-Pflicht dokumentiert (FileVault aktivieren)
- App verlaesst sich auf Festplattenverschluesselung durch das Betriebssystem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
2026-08-28 16:47:45 +02:00
Jeuner
09be478868 Update README for Linux setup instructions 2026-08-26 19:45:25 +02:00
Jeuner
6458c36d85 img fix 2026-08-25 17:32:44 +02:00
Jeuner
be0252c907 feat: Nextcloud-Profil im Footer
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
2026-08-25 02:19:03 +02:00
Jeuner
de9c269e9c fix: Telefonnummer im Footer im internationalen Format anzeigen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
2026-08-25 02:15:19 +02:00
Jeuner
441883a073 feat: LinkedIn und Telefonnummer im Footer
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
2026-08-25 02:14:15 +02:00
Jeuner
abf54765d8 fix: Rufnummern bei Testzugang serverseitig maskieren, nicht nur per CSS blurren
CSS-Blur (Sicher-Modus) verdeckt nur die Anzeige - im Seitenquelltext,
DevTools oder Netzwerk-Tab standen die echten Rufnummern trotzdem drin,
weil sie ganz normal im JSON von /api/anrufe und /api/verlauf ankamen.

Bei Testzugang (per Token statt Passwort, self._via_token) werden nummer/
rueckrufnummer jetzt vor dem Senden durch einen Platzhalter ersetzt
(_maskiere_anrufe), im Verlauf-Log werden eingebettete Ziffernfolgen ab
9 Stellen ersetzt (_maskiere_verlauf, gleiche Heuristik wie das bestehende
Frontend-telBlur, nur serverseitig und wirklich ersetzend statt nur
verdeckend). Die echten Werte verlassen den Server in diesem Modus nicht
mehr.

Getestet: 13 unmaskierte Rufnummern im Verlauf bei Passwort-Zugriff, 0 bei
Token-Zugriff; _maskiere_anrufe() isoliert verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
2026-08-25 02:11:41 +02:00
Jeuner
7dcb90826c feat: Sicher-Modus bei Testzugang erzwungen, nicht abschaltbar
Wer per "agent-one test"-Link kommt, braucht kein Passwort - also duerfen
fremde Tester keine echten Rufnummern sehen. /api/status liefert jetzt
"testzugang" (true, wenn die Anfrage per Zugangs-Token statt Passwort
authentifiziert wurde), das Frontend erzwingt darauf sicherSetzen(true) und
macht den Sicher-Modus-Button zum No-Op (Toast statt Abschalten).

Mit Playwright verifiziert: Laden mit gueltigem Token setzt Sicher-Modus
sofort, Klick auf den Button aendert daran nichts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
2026-08-25 01:47:03 +02:00
Jeuner
af67ee89e3 feat: signierte 1h-Testzugangslinks fuer den Leitstand ("agent-one test")
Gegenstueck zum "agent-one test"-Kommando in Alice (dillenberg.net,
dilles-agent.php). Kein Live-Aufruf zwischen den Systemen noetig - beide
teilen sich TESTZUGANG_SECRET, das Token ist ein HMAC-signierter
Ablauf-Zeitstempel ("ablauf.signatur"), stateless geprueft.

pipe/testzugang.py: gueltig(token) prueft Signatur (hmac.compare_digest)
und Ablauf. _angemeldet() in server.py akzeptiert das Token zusaetzlich zum
Passwort - per ?zugang=... in der URL oder per zugang-Cookie fuer
Folgeanfragen (fetch()-Aufrufe der Seite tragen die URL nicht mit). Ein
frisches URL-Token setzt das Cookie (HttpOnly, Secure, SameSite=Lax) fuer
den Rest der Stunde.

End-to-End getestet: PHP-generiertes Token wird von Python akzeptiert
(Cross-Language-HMAC-Kompatibilitaet bestaetigt), ungueltige Signatur und
abgelaufenes Token werden korrekt abgelehnt (401), Cookie-Folgeanfrage ohne
erneutes Token/Passwort funktioniert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
2026-08-25 01:33:46 +02:00
38 changed files with 1755 additions and 352 deletions

View file

@ -12,6 +12,12 @@ WHISPER_THREADS=8
WHISPER_MODELL=medium
WHISPER_COMPUTE=int8
# --- Branchen-Profil (Kategorien, Prompt, Sicherheitsnetz, Ansage) ---
# Waehlt den Ordner unter profile/ - buendelt alles, was fachlich vom
# Einsatzzweck abhaengt. Mitgeliefert: "praxis" (Hausarztpraxis, Default),
# "aufzug-notdienst" (Beispiel/Vorlage). Siehe README "Kategorien & Branchen-Profil".
PROFIL=praxis
# --- Kategorisierung ---
# Backend: "ollama" (Default, lokal) oder "openrouter" (Cloud, nur zum Testen
# ob ein staerkeres Modell besser extrahiert - Transkript verlaesst dabei den
@ -45,12 +51,31 @@ 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)
@ -85,6 +110,13 @@ 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
@ -96,3 +128,9 @@ 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

View file

@ -1,5 +1,44 @@
# 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

323
HANDOFF.md Normal file
View file

@ -0,0 +1,323 @@
# Handoff — Live-Telefonagent (laufend, zuletzt aktualisiert 2026-09-14)
## Stand in einem Satz
Voller Weg funktioniert Ende-zu-Ende: externer Anruf auf Durchwahl 9 (Plusnet → FreeSWITCH → `dialog/pipecat_bootstrap.py` → WebSocket → Astra) erreicht Astra, und Astra liefert jetzt auch eine echte, fertige Antwort statt nur "Einen Moment bitte" (Fix 2026-09-13, siehe unten) - kein offener Blocker mehr bekannt.
## 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:** `<key>AbandonProcessGroup</key><true/>` 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 `<X-PRE-PROCESS cmd="include" data="public/*.xml"/>`
**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_<name>.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**: `/private/tmp/astra_server.log` auf minim4-1 (NICHT
`.runtime/server.log` - das ist ein alter, verwaister Lauf). Bei Zweifel,
wohin ein laufender Prozess loggt: `lsof -p <pid> -a -d 0,1,2`.
- **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.

155
README.md
View file

@ -1,30 +1,31 @@
# Logpy:AgentOne
**v1.0.0** — siehe [CHANGELOG.md](CHANGELOG.md)
**v1.1.0** — siehe [CHANGELOG.md](CHANGELOG.md)
Praxis-Telefon-Agent — nimmt Anrufe (Sprachnachrichten) einer Hausarztpraxis entgegen, **transkribiert**
Anruf-Intake-Agent — nimmt Anrufe (Sprachnachrichten) entgegen, **transkribiert**
sie lokal, **kategorisiert** das Anliegen und **legt** das Ergebnis strukturiert
ab (lokal und optional in Nextcloud).
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`.
**Vollständig on-prem** — keine Patientendaten verlassen den Rechner.
**Vollständig on-prem** — keine Anrufdaten 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 ✅ — offen:
Monitoring/Alerting bei Diensteausfall, Verschlüsselung at rest.
3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅,
Verschlüsselung at rest ✅ (Betreiber-Pflicht: FileVault, siehe
[Datenschutz](#datenschutz-dsgvo)), Monitoring/Alerting bei Diensteausfall ✅
(`pipe/monitor.py`, siehe unten).
## Bausteine
@ -37,19 +38,62 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen.
| `pipe/config.py` | Konfiguration aus `.env` |
| `pipe/watch.py` | Beobachtet den Eingang und schiebt Aufnahmen durch die Pipe |
| `pipe/server.py` | Leitstand: Live-Ansicht mit Status, Anrufen und Verlauf |
| `pipe/dienste.py` | Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung, Nextcloud) — geteilt von Leitstand und Störungswache |
| `pipe/monitor.py` | Störungswache: alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt |
| `pipe/alarm.py` | Verschickt den lokalen macOS-Alarm (osascript) |
| `pipe/protokoll.py` | Ereignisprotokoll (JSONL) für den Leitstand |
| `pipe/audit.py` | Unveränderliches Audit-Log (append-only, siehe unten) |
| `pipe/loeschen.py` | Löscht erledigte Anrufe nach Ablauf der Aufbewahrungsfrist |
| `pipe/bewertung.py` | 👍/👎-Bewertung pro Anruf (Leitstand) |
| `pipe/export.py` | Exportiert bewertete Anrufe als JSONL-Trainingsdaten |
| `prompts/categorize_de.txt` | Kategorisierungs-Prompt (deutsch, mehrsprachig-tauglich) |
| `telefon/` | Ansage, FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte |
| `pipe/kategorien.py` | Lädt Kategorien-Schema des aktiven Branchen-Profils (siehe unten) |
| `profile/<name>/` | Branchen-Profil: Prompt, Kategorien, Sicherheitsnetz, Ansage |
| `telefon/` | FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte |
## Kategorien
## Kategorien & Branchen-Profil
`termin · rezept · ueberweisung · befund · verwaltung · beschwerden · notfall ·
rueckruf · sonstiges`
Dringlichkeit: `niedrig · normal · hoch · notfall`
Alles, was fachlich vom Einsatzzweck abhängt, steckt in einem **Branchen-Profil**
unter `profile/<name>/` — 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=<name>` setzen. Die Dringlichkeitsstufen
(`niedrig · normal · hoch · notfall`) sind **nicht** Teil des Profils — die
Eskalationsskala ist einsatzzweck-unabhängig und in Leitstand/Dashboard/Deck
fest verdrahtet (Farben, Sortierung, Notfall-Alarm).
> **Hinweis:** `prompt.txt` beschreibt die Kategorien in Prosa fürs Modell,
> `kategorien.json` erzwingt sie als Enum im JSON-Schema — bewusst zwei
> Dateien, aber sie müssen inhaltlich zusammenpassen. Ändert man die
> Kategorien-Liste, muss der Prompt-Text mitgezogen werden, sonst kann das
> Modell eine im Prompt beschriebene Kategorie wählen, die das Schema ablehnt.
## Einrichtung
@ -136,7 +180,7 @@ eingehende Anrufe an, spielt die Ansage und nimmt auf. Die Aufnahme landet unter
sie durch die Pipe und räumt sie nach `telefon/verarbeitet/` weg.
```bash
./telefon/ansage_bauen.sh # Ansage aus telefon/ansage.txt erzeugen
./telefon/ansage_bauen.sh # Ansage aus profile/$PROFIL/ansage.txt erzeugen
./telefon/freeswitch/einrichten.sh # Trunk + Dialplan in FreeSWITCH eintragen
./telefon/starten.sh # startet FreeSWITCH, Verarbeitung, Leitstand
```
@ -192,7 +236,37 @@ Der SIP-Zugang steht in der `.env` (`SIP_USER`, `SIP_DOMAIN`, `SIP_PASS`). Das
Einrichtungsskript trägt das Passwort in die FreeSWITCH-Konfiguration ein und
setzt deren Rechte auf 600 — im Git liegen nur Vorlagen mit Platzhaltern.
Text ändern: `telefon/ansage.txt` bearbeiten, `ansage_bauen.sh` erneut
### 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
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.
@ -247,6 +321,29 @@ in `telefon/audit.log` — anders als `verlauf.log` (fürs Leitstand-UI, wird au
zusätzlich per macOS `chflags uappnd` gegen nachträgliches Ändern/Kürzen
abgesichert (auch der eigene Prozess kommt danach nur noch per Anhängen rein).
## Störungswache (Monitoring/Alerting)
Die Dienste-Ampel im Leitstand (Telefon, Verarbeitung, Spracherkennung,
Nextcloud) zeigt den Zustand nur an, wenn gerade jemand die Seite offen hat.
`pipe/monitor.py` läuft deshalb als **eigener Prozess** (von
`telefon/starten.sh` mitgestartet, Log `telefon/monitor.log`), fragt dieselbe
Ampel (`pipe/dienste.py`) alle `MONITOR_TAKT_S` Sekunden (Default 60) ab und
alarmiert bei einer echten Störung (`schlecht`/`aus` — nicht bei `unklar`,
z. B. Nextcloud unkonfiguriert) lokal per macOS-Systembenachrichtigung + Ton
(`pipe/alarm.py`, `osascript`). Solange die Störung anhält, wird alle
`MONITOR_WIEDERHOLUNG_MIN` Minuten (Default 15) erneut erinnert; bei
Wiederherstellung einmalig „wieder ok".
Bewusst ein eigener, dritter Prozess statt in `pipe.watch` oder `pipe.server`
eingebaut: fällt einer der beiden aus, kann die Wache das trotzdem noch
melden. Wirkt nur lokal auf diesem Mac — für Alarme aufs Handy (z. B. per
Push/E-Mail) `pipe/alarm.py` erweitern.
```bash
python3 -m pipe.monitor # dauerhaft beobachten (normalerweise per starten.sh)
python3 -m pipe.monitor --einmal # einmal prüfen, für Tests
```
## Bewertung & Trainingsdaten-Export
Jede Karte im Leitstand hat 👍/👎 — war die Kategorisierung richtig? Bei 👎
@ -271,7 +368,21 @@ Gesundheitsdaten — Pflichten für den Produktivbetrieb (Stufe 2):
- Löschfristen — siehe oben (`LOESCHFRIST_TAGE`).
- Verarbeitung lokal (Whisper + Qwen) — kein Cloud-Dienst im Datenpfad.
> **Offener Punkt:** Die aktuelle `telefon/ansage.txt` erfüllt die ersten
> beiden Punkte noch nicht (kein Notfall-Hinweis, keine Aufnahme-Einwilligung)
> — Text anpassen und `ansage_bauen.sh` neu laufen lassen, bevor die Anlage im
> echten Praxisbetrieb ans Netz geht.
> **Offener Punkt:** Die aktuelle `profile/praxis/ansage.txt` erfüllt die
> ersten beiden Punkte noch nicht (kein Notfall-Hinweis, keine
> Aufnahme-Einwilligung) — Text anpassen und `ansage_bauen.sh` neu laufen
> lassen, bevor die Anlage im echten Praxisbetrieb ans Netz geht.
> **Verschlüsselung at rest — Pflicht des Betreibers:** Die Pipe legt
> Aufnahmen, Transkripte und Metadaten (`ablage/`, `telefon/`, `training/`)
> 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.

0
dialog/__init__.py Normal file
View file

68
dialog/anrufen.py Normal file
View file

@ -0,0 +1,68 @@
"""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 <Nummer>
"""
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 <Nummer>")
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()

187
dialog/pipecat_bootstrap.py Normal file
View file

@ -0,0 +1,187 @@
"""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 <subclass>` 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()

View file

@ -1,129 +0,0 @@
# 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 <http://127.0.0.1:8088>.
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 <pfad> <zeitlimit>
<stille-schwelle> <stille-treffer>`). 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

BIN
docs/logpy-agentone-1-1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

27
pipe/alarm.py Normal file
View file

@ -0,0 +1,27 @@
"""Lokaler macOS-Alarm (Systembenachrichtigung + Ton) für pipe.monitor.
Wirft nie - eine fehlgeschlagene Benachrichtigung darf die Wache nicht zum
Absturz bringen. Nutzt osascript (stdlib-Unterprozess, kein Framework, keine
Abhängigkeit von einem laufenden Nextcloud/Cloud-Dienst).
"""
from __future__ import annotations
import subprocess
from . import config
def _quote(text: str) -> str:
"""AppleScript-String-Literal - Anführungszeichen/Backslashes escapen."""
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
def sende(titel: str, text: str) -> None:
skript = (
f"display notification {_quote(text)} with title {_quote(titel)} "
f"sound name {_quote(config.MONITOR_TON)}"
)
try:
subprocess.run(["osascript", "-e", skript], capture_output=True, timeout=10)
except Exception as fehler:
print(f" Alarm konnte nicht zugestellt werden: {fehler}")

View file

@ -15,40 +15,33 @@ import urllib.error
import urllib.request
from functools import lru_cache
from . import config, modellwahl
from . import config, kategorien, modellwahl
KATEGORIEN = [
"termin", "rezept", "ueberweisung", "befund",
"verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges",
]
DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"]
KATEGORIEN = kategorien.KATEGORIEN
DRINGLICHKEITEN = kategorien.DRINGLICHKEITEN
# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: dieselben Symptome,
# die der Prompt selbst als Notfall-Beispiele nennt (Atemnot, Brustschmerz,
# Bewusstlosigkeit, starke Blutung). Grund: getestet mit qwen3:8b, 3/3
# identische Laeufe stuften "starke Brustschmerzen ... kriege kaum Luft" nur
# als "hoch" statt "notfall" ein - obwohl das Modell selbst "Brustschmerzen"
# und "Atemnot" als stichworte erkannte. Bei Notfall-Erkennung darf man sich
# nicht allein auf ein LLM verlassen. Eskaliert nur nach oben, nie nach unten
# - ein Fehlalarm kostet einen Blick, ein uebersehener Notfall kann einen
# Patienten kosten (siehe auch die "im Zweifel hoeher"-Regel im Prompt).
_NOTFALL_MUSTER = [re.compile(p, re.IGNORECASE) for p in (
r"atemnot",
r"(kriege|bekomme|krieg)\w*\s+(kaum|keine|schwer)\s+luft",
r"kann\s+(kaum|nicht)\s+(mehr\s+)?atmen",
r"brust\w*schmerz",
r"schmerzen?\s+in\s+der\s+brust",
r"bewusstlos",
r"ohnm[aä]chtig",
r"nicht\s+ansprechbar",
r"blutet\s+(stark|heftig|sehr)",
r"starke?\s+blutung",
)]
@lru_cache(maxsize=1)
def _sicherheitsnetz_muster() -> list[re.Pattern]:
"""Lädt die Eskalations-Regex-Muster aus profile/<PROFIL>/sicherheitsnetz.txt.
Ein Muster pro Zeile, Kommentare ("#") und Leerzeilen werden übersprungen.
Deterministisch, unabhängig vom LLM - siehe Rationale in der Profil-Datei
selbst. Eskaliert nur nach oben, nie nach unten.
"""
if not config.SICHERHEITSNETZ_DATEI.is_file():
return []
return [
re.compile(zeile, re.IGNORECASE)
for zeile in (z.strip() for z in config.SICHERHEITSNETZ_DATEI.read_text(encoding="utf-8").splitlines())
if zeile and not zeile.startswith("#")
]
def _notfall_sicherheitsnetz(transkript: str, d: dict) -> dict:
if d.get("dringlichkeit") != "notfall" and any(m.search(transkript) for m in _NOTFALL_MUSTER):
d["kategorie"] = "notfall"
ziel = kategorien.SICHERHEITSNETZ_KATEGORIE
if d.get("kategorie") != ziel and any(m.search(transkript) for m in _sicherheitsnetz_muster()):
d["kategorie"] = ziel
d["dringlichkeit"] = "notfall"
return d
@ -244,11 +237,11 @@ def _bereinige(d: dict) -> dict:
d["kategorie"] = "sonstiges"
if d.get("dringlichkeit") not in DRINGLICHKEITEN:
d["dringlichkeit"] = "normal"
# Das Modell vergibt gelegentlich die Kategorie "notfall" und stuft die
# Dringlichkeit trotzdem nur auf "hoch" ein. Das ist widersprüchlich und
# führt zu einer harmloseren Farbe auf dem Board, als der Anruf verdient.
# In der Praxis eskaliert man im Zweifel, statt abzuschwächen.
if d["kategorie"] == "notfall":
# Das Modell vergibt gelegentlich die Sicherheitsnetz-Kategorie (z. B.
# "notfall") und stuft die Dringlichkeit trotzdem nur auf "hoch" ein. Das
# ist widersprüchlich und führt zu einer harmloseren Farbe auf dem Board,
# als der Anruf verdient. Im Zweifel eskaliert man, statt abzuschwächen.
if d["kategorie"] == kategorien.SICHERHEITSNETZ_KATEGORIE:
d["dringlichkeit"] = "notfall"
d.setdefault("anrufer_name", None)
d.setdefault("rueckrufnummer", None)

View file

@ -83,9 +83,21 @@ OPENROUTER_KEY = os.environ.get("OPENROUTER_KEY", "")
# hinterlegten (siehe pipe/modellwahl.py) - leer lassen wenn nicht gebraucht.
OPENROUTER_MODELL = os.environ.get("OPENROUTER_MODELL", "")
PROMPT_DATEI = WURZEL / "prompts" / os.environ.get(
"PROMPT_DATEI", "categorize_de.txt"
)
# --- Branchen-Profil -----------------------------------------------------
# Buendelt alles, was fachlich vom Einsatzzweck abhaengt (Kategorien, Prompt,
# Notfall-Sicherheitsnetz, Ansage) an einer Stelle - siehe profile/<name>/.
# 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/<name>/ Ordner
# mit prompt.txt, kategorien.json, sicherheitsnetz.txt, ansage.txt anlegen
# (profile/praxis/ als Vorlage nehmen) und hier PROFIL=<name> setzen.
PROFIL = os.environ.get("PROFIL", "praxis").strip() or "praxis"
PROFIL_ORDNER = WURZEL / "profile" / PROFIL
PROMPT_DATEI = PROFIL_ORDNER / "prompt.txt"
KATEGORIEN_DATEI = PROFIL_ORDNER / "kategorien.json"
SICHERHEITSNETZ_DATEI = PROFIL_ORDNER / "sicherheitsnetz.txt"
ANSAGE_DATEI = PROFIL_ORDNER / "ansage.txt"
# --- Ablage ------------------------------------------------------------------
ABLAGE_LOKAL = Path(os.environ.get("ABLAGE_LOKAL", str(WURZEL / "ablage")))
@ -105,6 +117,28 @@ 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:
@ -162,6 +196,19 @@ def nextcloud_aktiv() -> bool:
return bool(NEXTCLOUD_URL and NEXTCLOUD_USER and NEXTCLOUD_PASS)
# --- Störungswache (pipe.monitor) --------------------------------------------
# Prueft die Dienste-Ampel (pipe.dienste: Telefon, Verarbeitung, Spracherkennung,
# Nextcloud) periodisch und alarmiert lokal per macOS-Benachrichtigung + Ton,
# wenn niemand auf den Leitstand schaut. Eigener Prozess (siehe
# telefon/starten.sh) - faellt pipe.watch oder pipe.server aus, meldet die
# Wache das trotzdem noch.
MONITOR_TAKT_S = int(os.environ.get("MONITOR_TAKT_S", "60") or "60")
# Erinnerungsabstand, waehrend eine Stoerung anhaelt - sonst nur ein einziger
# Alarm beim Ausfall, der leicht untergeht.
MONITOR_WIEDERHOLUNG_MIN = int(os.environ.get("MONITOR_WIEDERHOLUNG_MIN", "15") or "15")
# macOS-Systemton (siehe /System/Library/Sounds), der bei jedem Alarm abgespielt wird.
MONITOR_TON = os.environ.get("MONITOR_TON", "Sosumi").strip() or "Sosumi"
# --- Aufbewahrungsfrist (pipe.loeschen) --------------------------------------
# Nach so vielen Tagen wird ein Anruf-Ordner (Aufnahme + Transkript + Meta)
# geloescht - lokal und, falls hochgeladen, auch bei Nextcloud. 0 = aus (nichts

View file

@ -11,15 +11,11 @@ import json
import sys
from pathlib import Path
from . import config
from . import config, kategorien
RANG = {"notfall": 0, "hoch": 1, "normal": 2, "niedrig": 3}
FARBE = {"notfall": "#E9322D", "hoch": "#E0A339", "normal": "#C9A227", "niedrig": "#31CC7C"}
KAT_LABEL = {
"termin": "Termin", "rezept": "Rezept", "ueberweisung": "Überweisung",
"befund": "Befund", "verwaltung": "Verwaltung", "beschwerden": "Beschwerden", "notfall": "Notfall",
"rueckruf": "Rückruf", "sonstiges": "Sonstiges",
}
RANG = kategorien.DRINGLICHKEIT_RANG
FARBE = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()}
KAT_LABEL = kategorien.KATEGORIE_LABEL
def _anrufe() -> list[dict]:
@ -65,7 +61,7 @@ def baue(ausgabe: Path) -> Path:
karten = "\n".join(_karte(d) for d in anrufe)
doc = f"""<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anruf-Übersicht Praxis Musterhausen</title>
<title>Anruf-Übersicht · {html.escape(kategorien.PROFIL_NAME)}</title>
<style>
:root{{--bg:#f6f7f9;--fg:#1a2230;--karte:#fff;--rand:#e4e8ee;--muted:#6b7688}}
@media(prefers-color-scheme:dark){{:root{{--bg:#12151b;--fg:#e7ecf3;--karte:#1b2029;--rand:#2a313d;--muted:#93a0b4}}}}
@ -86,7 +82,7 @@ padding:1px 7px;font-size:12px;color:var(--muted);margin:2px 4px 2px 0}}
details{{margin-top:6px}}summary{{cursor:pointer;color:var(--muted);font-size:13px}}
.tr{{color:var(--muted);font-size:13px;margin:6px 0 0}}
</style></head><body><div class="wrap">
<h1>Anruf-Übersicht</h1>
<h1>Anruf-Übersicht · {html.escape(kategorien.PROFIL_NAME)}</h1>
<p class="sub">{len(anrufe)} Anrufe · {zusammenfassung or ''} · lokal erzeugt aus der Ablage</p>
{karten}
</div></body></html>"""

View file

@ -18,24 +18,15 @@ import urllib.error
import urllib.request
from functools import lru_cache
from . import config
from . import config, kategorien
_API = "/index.php/apps/deck/api/v1.1"
# Farben (6-stelliges Hex ohne #) je Dringlichkeit.
DRINGLICHKEIT_FARBE = {
"niedrig": "31CC7C", # grün
"normal": "F1DB50", # gelb
"hoch": "E0A339", # orange
"notfall": "E9322D", # rot
}
# Farben je Dringlichkeit - siehe pipe/kategorien.py.
DRINGLICHKEIT_FARBE = kategorien.DRINGLICHKEIT_FARBE
# Lesbare Namen der Kategorien für den Kartentitel - der Stapel sagt jetzt,
# wie weit die Bearbeitung ist, nicht mehr worum es geht.
KATEGORIE_LABEL = {
"termin": "Termin", "rezept": "Rezept", "ueberweisung": "Überweisung",
"befund": "Befund", "verwaltung": "Verwaltung", "beschwerden": "Beschwerden", "notfall": "Notfall",
"rueckruf": "Rückruf", "sonstiges": "Sonstiges",
}
KATEGORIE_LABEL = kategorien.KATEGORIE_LABEL
@lru_cache(maxsize=1)

96
pipe/dienste.py Normal file
View file

@ -0,0 +1,96 @@
"""Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung,
Nextcloud) geteilt zwischen Leitstand (pipe.server) und Störungswache
(pipe.monitor), damit beide dieselbe Sicht auf "läuft/steht" haben.
"""
from __future__ import annotations
import subprocess
import time
import urllib.error
import urllib.request
from datetime import datetime
from . import config, store
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
# damit haeufiges Nachfragen den Rechner nicht belastet.
_CACHE: dict[str, tuple[float, dict]] = {}
CACHE_S = 4.0
def _laeuft(muster: str) -> bool:
try:
return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0
except Exception:
return False
def _http_ok(url: str, timeout: float = 5.0) -> bool:
"""Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud
blockt den Standardnamen von urllib und meldete fälschlich 'nicht
erreichbar'."""
req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()):
return True
except urllib.error.HTTPError:
return True # antwortet - reicht als Lebenszeichen
except Exception:
return False
def _trunk() -> tuple[str, str]:
"""(zustand, text) des SIP-Trunks."""
if not _laeuft("[f]reeswitch"):
return "aus", "FreeSWITCH läuft nicht"
try:
roh = subprocess.run(
["fs_cli", "-P", config.FS_PORT, "-x", "sofia status gateway plusnet"],
capture_output=True, text=True, timeout=8).stdout
for zeile in roh.splitlines():
if zeile.startswith("Status"):
wert = zeile.split()[-1]
return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}")
return "unklar", "Gateway unbekannt"
except Exception:
return "unklar", "fs_cli nicht erreichbar"
def status() -> dict:
jetzt = time.time()
gepuffert = _CACHE.get("status")
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
return gepuffert[1]
trunk_zustand, trunk_text = _trunk()
watcher = _laeuft("[p]ipe.watch")
ollama = _http_ok(f"{config.OLLAMA_URL}/api/version")
nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php")
if config.nextcloud_aktiv() else None)
offen = 0
if config.TELEFON_EINGANG.is_dir():
offen = sum(1 for d in config.TELEFON_EINGANG.iterdir()
if d.is_file() and not d.name.startswith("."))
fehler = 0
if config.TELEFON_FEHLER.is_dir():
fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file())
daten = {
"dienste": [
{"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text},
{"name": "Verarbeitung", "zustand": "gut" if watcher else "aus",
"text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"},
{"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht",
"text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"},
{"name": "Nextcloud", "zustand": "unklar" if nextcloud is None
else ("gut" if nextcloud else "schlecht"),
"text": "nicht konfiguriert" if nextcloud is None
else ("erreichbar" if nextcloud else "nicht erreichbar")},
],
"eingang_offen": offen,
"fehler": fehler,
"stand": datetime.now().strftime("%H:%M:%S"),
}
_CACHE["status"] = (jetzt, daten)
return daten

48
pipe/kategorien.py Normal file
View file

@ -0,0 +1,48 @@
"""Kategorien-Schema des aktiven Branchen-Profils (profile/<PROFIL>/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")

View file

@ -5,49 +5,104 @@ Der Watcher (pipe.watch, Langzeit-Prozess) und der Leitstand (pipe.server,
eigener Prozess) laufen getrennt - die Auswahl wird deshalb in einer kleinen
JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein
Caching), damit eine Umschaltung sofort beim naechsten Anruf greift.
Die lokalen Ollama-Modelle werden live von Ollama abgefragt (GET /api/tags)
statt fest im Code zu stehen - jedes per "ollama pull" installierte Modell
taucht automatisch im Dropdown auf, ohne Code-Aenderung. Eine feste
OpenRouter-Testauswahl bleibt daneben bestehen: die Modell-IDs dort lassen
sich nicht lokal verifizieren, ein Tippfehler wuerde sonst erst beim naechsten
Anruf sichtbar - fuer freie Eingabe siehe OPENROUTER_MODELL in der .env.
"""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from . import config
DATEI = config.TELEFON / "modell.json"
# Feste, kuratierte Auswahl statt freier Eingabe - ein Tippfehler in einer
# Modell-ID im Leitstand darf nicht den naechsten Anruf scheitern lassen.
# "openrouter"-Eintraege sind zum Testen gedacht (Transkript verlaesst dabei
# den Rechner) - nicht fuer den Praxisbetrieb.
AUSWAHL = [
{"id": "ollama", "label": f"{config.OLLAMA_MODELL} (lokal)",
"backend": "ollama", "modell": config.OLLAMA_MODELL},
{"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)",
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"},
{"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)",
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"},
]
# Optionaler vierter Eintrag zum schnellen Ausprobieren eines beliebigen
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in
# .env setzen.
if config.OPENROUTER_MODELL:
AUSWAHL.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)",
"backend": "openrouter", "modell": config.OPENROUTER_MODELL})
_STANDARD = AUSWAHL[0]["id"]
_NACH_ID = {e["id"]: e for e in AUSWAHL}
# Ollama-Abfrage ist ein Netzwerkaufruf - kurz zwischenspeichern, damit ein
# offener Leitstand (Polling alle paar Sekunden) Ollama nicht dauerbelastet.
_CACHE: dict[str, tuple[float, list[dict]]] = {}
CACHE_S = 30.0
def _ollama_modelle() -> list[str]:
"""Namen der lokal installierten, textfähigen Ollama-Modelle (leer bei Fehler).
Reine Embedding-Modelle (z. B. nomic-embed-text) werden ausgefiltert - sie
können nicht kategorisieren, ein Aufruf würde nur mit einem Fehler enden.
Ältere Ollama-Versionen liefern kein "capabilities"-Feld - dann bleibt das
Modell sicherheitshalber drin, statt es zu Unrecht zu verstecken.
"""
req = urllib.request.Request(f"{config.OLLAMA_URL}/api/tags",
headers={"User-Agent": "praxis-telefon-agent/1.0"})
try:
with urllib.request.urlopen(req, timeout=5) as antwort:
daten = json.loads(antwort.read().decode("utf-8"))
except Exception:
return []
namen = []
for m in daten.get("models", []):
name = m.get("name")
faehigkeiten = m.get("capabilities")
if name and (faehigkeiten is None or "completion" in faehigkeiten):
namen.append(name)
return sorted(namen)
def auswahl() -> list[dict]:
"""Aktuelle Dropdown-Optionen: lokale Ollama-Modelle live + feste OpenRouter-Testeintraege."""
jetzt = time.time()
gepuffert = _CACHE.get("auswahl")
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
return gepuffert[1]
# Ist Ollama gerade nicht erreichbar, bleibt wenigstens die konfigurierte
# Standardauswahl im Dropdown - ein leeres Dropdown waere schlechter.
lokale = _ollama_modelle() or [config.OLLAMA_MODELL]
liste = [{"id": f"ollama:{name}", "label": f"{name} (lokal)",
"backend": "ollama", "modell": name} for name in lokale]
liste.append({"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)",
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"})
liste.append({"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)",
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"})
# Optionaler Eintrag zum schnellen Ausprobieren eines beliebigen
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in
# .env setzen.
if config.OPENROUTER_MODELL:
liste.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)",
"backend": "openrouter", "modell": config.OPENROUTER_MODELL})
_CACHE["auswahl"] = (jetzt, liste)
return liste
def _standard_id(optionen: list[dict]) -> str:
"""Bevorzugt das in der .env konfigurierte Modell, sonst die erste Option."""
for eintrag in optionen:
if eintrag["backend"] == "ollama" and eintrag["modell"] == config.OLLAMA_MODELL:
return eintrag["id"]
return optionen[0]["id"]
def aktuelle_id() -> str:
"""Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard."""
optionen = auswahl()
standard = _standard_id(optionen)
try:
wert = json.loads(DATEI.read_text(encoding="utf-8")).get("id")
except Exception:
return _STANDARD
return wert if wert in _NACH_ID else _STANDARD
return standard
return wert if wert in {e["id"] for e in optionen} else standard
def setze(kennung: str) -> bool:
"""Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist."""
if kennung not in _NACH_ID:
"""Schreibt die Auswahl. False, wenn die ID nicht (mehr) vorgesehen ist."""
if kennung not in {e["id"] for e in auswahl()}:
return False
try:
DATEI.parent.mkdir(parents=True, exist_ok=True)
@ -59,5 +114,6 @@ def setze(kennung: str) -> bool:
def backend_und_modell() -> tuple[str, str]:
"""Backend + Modell-ID der aktiven Auswahl, fuer categorize.py."""
eintrag = _NACH_ID[aktuelle_id()]
kennung = aktuelle_id()
eintrag = next(e for e in auswahl() if e["id"] == kennung)
return eintrag["backend"], eintrag["modell"]

70
pipe/monitor.py Normal file
View file

@ -0,0 +1,70 @@
"""Störungswache: prüft die Dienste-Ampel (pipe.dienste) periodisch und
alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt -
auch wenn gerade niemand auf den Leitstand schaut.
python3 -m pipe.monitor [--einmal]
Läuft als eigener Prozess (siehe telefon/starten.sh), unabhängig von
pipe.watch und pipe.server - fällt einer der beiden aus, kann die Wache das
trotzdem noch melden. Alarmiert nur bei echten Störungen ("schlecht"/"aus"),
nicht bei "unklar" (z. B. Nextcloud nicht konfiguriert - das ist gewollt,
kein Fehler).
"""
from __future__ import annotations
import argparse
import time
from . import alarm, config, dienste
GESTOERT = {"schlecht", "aus"}
def pruefe(letzte_alarme: dict[str, float]) -> None:
"""Ein Durchlauf: Ampel abfragen, bei Bedarf alarmieren.
`letzte_alarme` hält je Dienstname den Zeitpunkt (time.monotonic) des
letzten Alarms - fehlt ein Name, gilt der Dienst aktuell als nicht
gestört. Der Aufrufer reicht dasselbe dict über alle Durchläufe weiter.
"""
jetzt = time.monotonic()
for dienst in dienste.status()["dienste"]:
name, zustand, text = dienst["name"], dienst["zustand"], dienst["text"]
letzter_alarm = letzte_alarme.get(name)
if zustand in GESTOERT:
if letzter_alarm is None:
alarm.sende(f"{name} gestört", text)
letzte_alarme[name] = jetzt
elif jetzt - letzter_alarm >= config.MONITOR_WIEDERHOLUNG_MIN * 60:
alarm.sende(f"{name} weiterhin gestört", text)
letzte_alarme[name] = jetzt
elif zustand == "gut" and letzter_alarm is not None:
alarm.sende(f"{name} wieder ok", text)
del letzte_alarme[name]
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="Dienste-Ampel beobachten und bei Störung lokal alarmieren.")
p.add_argument("--einmal", action="store_true",
help="nur einmal prüfen statt dauerhaft zu beobachten")
args = p.parse_args(argv)
letzte_alarme: dict[str, float] = {}
if args.einmal:
pruefe(letzte_alarme)
return 0
print(f"Störungswache aktiv - Takt {config.MONITOR_TAKT_S}s, "
f"Erinnerung alle {config.MONITOR_WIEDERHOLUNG_MIN} min - Abbruch mit Strg-C")
try:
while True:
pruefe(letzte_alarme)
time.sleep(config.MONITOR_TAKT_S)
except KeyboardInterrupt:
print("\nBeobachtung beendet.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import json
import shutil
import time
from datetime import datetime
from pathlib import Path
@ -79,3 +80,26 @@ def archiviere_und_leere() -> Path | None:
return ziel
except Exception:
return None
ARCHIV_FRIST_TAGE = 90
def raeume_archiv_auf(tage: int = ARCHIV_FRIST_TAGE) -> int:
"""Löscht Verlauf-Archivkopien (nicht audit.log!) älter als `tage` Tage.
Ein neues verlauf_*.log entsteht bei jedem "Tag archivieren" - ohne
Aufräumen sammeln sich diese sonst unbegrenzt an. Wirft nie, gibt die
Anzahl gelöschter Dateien zurück."""
if not ARCHIV_ORDNER.is_dir():
return 0
grenze = time.time() - tage * 86400
geloescht = 0
for datei in ARCHIV_ORDNER.glob("verlauf_*.log"):
try:
if datei.stat().st_mtime < grenze:
datei.unlink()
geloescht += 1
except Exception:
continue
return geloescht

File diff suppressed because one or more lines are too long

35
pipe/testzugang.py Normal file
View file

@ -0,0 +1,35 @@
"""Signierte, zeitlich begrenzte Testzugangs-Links zum Leitstand.
Gegenstueck zu dilles_agent_one_test_command() in dillenberg.net's
dilles-agent.php ("agent-one test"-Kommando im Alice-Chat). Beide Seiten
teilen sich TESTZUGANG_SECRET (config.py) - der Link wird komplett auf der
PHP-Seite erzeugt, ohne Live-Aufruf hierher; hier wird nur die Signatur
geprueft. Stateless: kein Server-seitiger Speicher fuer ausgegebene Tokens,
die Gueltigkeit steckt komplett im signierten Ablauf-Zeitstempel.
Token-Format: "<ablauf-unixzeit>.<hmac-sha256-hexdigest>"
"""
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

View file

@ -152,10 +152,18 @@ def main(argv: list[str] | None = None) -> int:
try:
while True:
durchlauf(eingang)
if config.LOESCHFRIST_TAGE and time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S:
anzahl = loeschen.laeuft()
if anzahl:
print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.")
if time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S:
if config.LOESCHFRIST_TAGE:
anzahl = loeschen.laeuft()
if anzahl:
print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.")
# Verlauf-Archivkopien (pipe.protokoll, "Tag archivieren")
# sammeln sich sonst unbegrenzt an - unabhaengig von
# LOESCHFRIST_TAGE, das nur Anrufe betrifft, nicht Log-Kopien.
archiv_geloescht = protokoll.raeume_archiv_auf()
if archiv_geloescht:
print(f"🗑 {archiv_geloescht} alte Verlauf-Archivkopie(n) geloescht "
f"(> {protokoll.ARCHIV_FRIST_TAGE} Tage).")
letzter_loeschlauf = time.monotonic()
time.sleep(TAKT_S)
except KeyboardInterrupt:

Binary file not shown.

View file

@ -0,0 +1 @@
Aufzug-Notdienst, Testansage. Bitte hinterlassen Sie eine Nachricht nach dem Signal.

View file

@ -0,0 +1,12 @@
{
"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"
}

View file

@ -0,0 +1,44 @@
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": "<eine der Kategorien>",
"dringlichkeit": "<niedrig|normal|hoch|notfall>",
"anrufer_name": "<Name oder null>",
"rueckrufnummer": "<Telefonnummer aus dem Text oder null>",
"rueckruf_gewuenscht": <true|false>,
"email": "<E-Mail-Adresse aus dem Text oder null, im Format name@domain.tld>",
"sprache": "<ISO-Code der Anrufersprache, z. B. de, en, ar>",
"anliegen_kurz": "<1-2 Sätze, neutrale Zusammenfassung auf Deutsch>",
"stichworte": ["<kurze Schlagworte, z. B. Adresse/Gebäude, Aufzug-Nr.>"]
}

View file

@ -0,0 +1,16 @@
# 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

View file

@ -0,0 +1,19 @@
{
"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"
}

View file

@ -0,0 +1,23 @@
# 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

View file

@ -6,3 +6,7 @@ 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

View file

@ -1,16 +1,20 @@
#!/bin/bash
# Erzeugt die Telefonansage aus telefon/ansage.txt.
# Erzeugt die Telefonansage aus profile/$PROFIL/ansage.txt (Branchen-Profil,
# siehe README).
# Ergebnis: telefon/ansage.wav in Telefonqualitaet (8 kHz mono, PCM16).
#
# Drei Sprachsynthesen:
# Vier Quellen:
# 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.<ext>,
# ansage.txt wird dabei nicht gelesen)
#
# Steuerung ueber Umgebungsvariablen bzw. .env:
# ANSAGE_TTS=piper|say|mistral
# PROFIL=<Ordnername unter profile/, Default "praxis">
# ANSAGE_TTS=piper|say|mistral|datei
# ANSAGE_STIMME=<Piper-Modellname, say-Stimme, oder Voxtral-Stimmen-Slug>
# ANSAGE_EMOTION=<Sprecher-ID, nur bei thorsten_emotional: 0=amused,
# 1=angry, 2=disgusted, 3=drunk, 4=neutral, 5=sleepy,
@ -32,6 +36,10 @@ if [ -f .env ]; then
done < .env
fi
PROFIL="${PROFIL:-praxis}"
ANSAGE_TXT="profile/$PROFIL/ansage.txt"
[ -f "$ANSAGE_TXT" ] || { echo "Ansage-Text fehlt: $ANSAGE_TXT (PROFIL=$PROFIL)" >&2; exit 1; }
STIMMEN_ORDNER="${PIPER_STIMMEN:-$HOME/piper-voices}"
STIMME="${ANSAGE_STIMME:-de_DE-thorsten_emotional-medium}"
EMOTION="${ANSAGE_EMOTION:-0}"
@ -50,10 +58,28 @@ fi
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
case "$TTS" in
datei)
# Echte Aufnahme statt Sprachsynthese - profile/$PROFIL/ansage.<ext>
# (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' ' ' < telefon/ansage.txt \
tr '\n' ' ' < "$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"
@ -61,7 +87,7 @@ case "$TTS" in
;;
say)
SAY_STIMME="${ANSAGE_STIMME:-Anna}"
say -v "$SAY_STIMME" -r 180 -f telefon/ansage.txt -o "$TMP/sprache.aiff"
say -v "$SAY_STIMME" -r 180 -f "$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"
;;
@ -69,7 +95,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' ' ' < telefon/ansage.txt)"
TEXT="$(tr '\n' ' ' < "$ANSAGE_TXT")"
PAYLOAD="$(TTS_MODEL="$MISTRAL_MODELL" TTS_VOICE="$MISTRAL_STIMME" python3 -c '
import json, os, sys
print(json.dumps({

View file

@ -0,0 +1,30 @@
<include>
<!-- Durchwahl 3: Fonial klingelt bei dieser DDI offenbar PARALLEL an
AgentOne und an MainMacPhone (dem Endgeraet mit Durchwahl 3). Ohne
diese Regel beantwortet AgentOne jeden Anruf sofort (answer() ist die
erste Aktion in 00_praxis_ab.xml, keine Verzoegerung davor) - AgentOne
gewinnt das Rennen daher immer, bevor am anderen Mac ueberhaupt
geklingelt hat. deflect() (siptrace-verifiziert: nie tatsaechlich ein
302 verschickt) hat rein zufaellig als Wartezeit gewirkt und damit
MainMacPhone eine Chance gegeben.
Diese Regel macht das Warten explizit: bei erkannter DW3
(X-ORIGINAL-DDI-URI, da destination_number bei diesem Trunk
unbrauchbar ist) erstmal nur schlafen. Nimmt jemand an MainMacPhone
ab, schickt Plusnet ein CANCEL - der Kanal wird beendet, bevor der
sleep() zu Ende ist, alles Weitere unten laeuft nie. Nimmt niemand ab,
laeuft der Dialplan nach dem sleep() normal weiter zur Ansage+Aufnahme,
identisch zu AgentOnes Anrufbeantworter (00_praxis_ab.xml). -->
<extension name="dw3_warten">
<condition field="${sip_h_X-ORIGINAL-DDI-URI}" expression="@@DW3_DDI@@">
<action application="sleep" data="20000"/>
<action application="set" data="record_stereo=false"/>
<action application="set" data="RECORD_ANSWER_REQ=true"/>
<action application="answer"/>
<action application="sleep" data="700"/>
<action application="playback" data="@@PROJEKT@@/telefon/ansage.wav"/>
<action application="record"
data="@@PROJEKT@@/telefon/eingang/${strftime(%Y%m%d-%H%M%S)}_${caller_id_number}.wav 120 200 4"/>
<action application="hangup"/>
</condition>
</extension>
</include>

View file

@ -0,0 +1,21 @@
<include>
<!-- Durchwahl 9 (rufagent) -> Astra-Telefonagent, siehe
dialog/pipecat_bootstrap.py (Port 8095) und
astra/telephony.py::FreeswitchAudioStreamSerializer.
Wie bei dw3_warten (dw3_warten.xml.tpl): destination_number ist bei
diesem Trunk unbrauchbar, die tatsaechlich gewaehlte DDI kommt nur
ueber den Header X-ORIGINAL-DDI-URI.
Dateiname wichtig: muss alphabetisch VOR 00_praxis_ab.xml einsortiert
werden (00_dw9... < 00_praxis...) - public/*.xml wird per
X-PRE-PROCESS alphabetisch eingebunden, und praxis_ab.xml's Catch-all
(^.*$, kein continue="true") stoppt die Dialplan-Auswertung sonst
schon vorher. Am 2026-09-13 gefunden: eine erste Fassung unter dem
Namen "00_rufagent_astra.xml" wurde nie erreicht, weil "r" nach
"p" kommt - siehe HANDOFF.md. -->
<extension name="dw9_rufagent">
<condition field="${sip_h_X-ORIGINAL-DDI-URI}" expression="@@DW9_DDI@@">
<action application="socket" data="127.0.0.1:8095 async full"/>
</condition>
</extension>
</include>

View file

@ -44,6 +44,8 @@ fuelle() { # <vorlage> <ziel>
-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"
}
@ -55,3 +57,20 @@ 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 "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

View file

@ -18,6 +18,33 @@ 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"; }
@ -65,7 +92,14 @@ 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.
freeswitch -nc -rp -nonatmap \
# -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 \
-conf "$FS_ETC" \
-log /opt/homebrew/var/log/freeswitch \
-db /opt/homebrew/var/lib/freeswitch/db \
@ -74,6 +108,30 @@ 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.
@ -99,15 +157,27 @@ if [ "${status:-}" = "UP" ]; then
else
# nice 10: Transkription und Kategorisierung dürfen warten, ein laufendes
# Telefonat nicht. Auf 16 GB RAM konkurrieren beide sonst um die Maschine.
nohup nice -n 10 python3 -u -m pipe.watch > telefon/watcher.log 2>&1 &
nohup nice -n 10 python3 -u -m pipe.watch >> telefon/watcher.log 2>&1 &
echo "Verarbeitung gestartet (Log: telefon/watcher.log)"
fi
if pgrep -f "[p]ipe.server" >/dev/null; then
echo "Leitstand laeuft bereits."
else
nohup python3 -u -m pipe.server > telefon/leitstand.log 2>&1 &
nohup python3 -u -m pipe.server >> telefon/leitstand.log 2>&1 &
sleep 2
head -1 telefon/leitstand.log
tail -1 telefon/leitstand.log
fi
# 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
fi
sleep 2
echo