Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

40 changed files with 353 additions and 1869 deletions

View file

@ -12,12 +12,6 @@ WHISPER_THREADS=8
WHISPER_MODELL=medium
WHISPER_COMPUTE=int8
# --- Branchen-Profil (Kategorien, Prompt, Sicherheitsnetz, Ansage) ---
# Waehlt den Ordner unter profile/ - buendelt alles, was fachlich vom
# Einsatzzweck abhaengt. Mitgeliefert: "praxis" (Hausarztpraxis, Default),
# "aufzug-notdienst" (Beispiel/Vorlage). Siehe README "Kategorien & Branchen-Profil".
PROFIL=praxis
# --- Kategorisierung ---
# Backend: "ollama" (Default, lokal) oder "openrouter" (Cloud, nur zum Testen
# ob ein staerkeres Modell besser extrahiert - Transkript verlaesst dabei den
@ -51,31 +45,12 @@ NEXTCLOUD_ADRESSBUCH=contacts
# Anrufe bleiben unangetastet, egal wie alt.
LOESCHFRIST_TAGE=0
# --- Störungswache (pipe.monitor, eigener Prozess, siehe telefon/starten.sh) ---
# Prüft die Dienste-Ampel (Telefon, Verarbeitung, Spracherkennung, Nextcloud)
# und alarmiert lokal per macOS-Benachrichtigung + Ton, wenn niemand auf den
# Leitstand schaut.
MONITOR_TAKT_S=60
MONITOR_WIEDERHOLUNG_MIN=15
MONITOR_TON=Sosumi
# --- Telefonie (SIP-Zugang der Praxis, leer lassen = keine Telefonannahme) ---
SIP_USER=
SIP_DOMAIN=sip.plusnet.de
SIP_PASS=
SIP_PROXY=voice01.sip.plusnet.de
# --- Durchwahl 3: eigenes Endgeraet vor dem Anrufbeantworter klingeln lassen ---
# Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim
# Anrufbeantworter. Manche Provider (z.B. Fonial/Plusnet) klingeln bei einer
# Durchwahl PARALLEL an mehreren registrierten Zielen - ohne Wartezeit
# gewinnt AgentOne das Rennen immer, weil es sofort abnimmt (answer() ist die
# erste Aktion im Dialplan). Die DDI kommt bei Plusnet nicht im
# destination_number an (der ist bei diesem Trunk unbrauchbar), sondern im
# Header X-ORIGINAL-DDI-URI - siehe telefon/freeswitch/dw3_warten.xml.tpl.
# Nur Ziffern, kein "+".
DW3_DDI=
# --- Ansage ---
# piper = lokal, natuerlich, Modelle unter ~/piper-voices
# say = lokal, macOS-Bordmittel (Rueckfall wenn Piper fehlt)
@ -110,13 +85,6 @@ LEITSTAND_HOST=127.0.0.1
LEITSTAND_PORT=8088
LEITSTAND_USER=praxis
LEITSTAND_PASS=
# Weitere Konten zusaetzlich zum obigen - kommagetrennt "nutzer:passwort",
# z.B. fuer ein zweites Team/einen zweiten Einsatzzweck neben "praxis".
LEITSTAND_ZUGAENGE=
# Optional: geteiltes Geheimnis fuer signierte, 1h gueltige Testzugangslinks
# (z.B. per Chatbot-Kommando ausgegeben). Leer = Feature aus.
TESTZUGANG_SECRET=
# --- Telefonanlage (nur Stufe 2) ---
# Event-Socket-Port von FreeSWITCH. Muss zu listen-port in
@ -128,9 +96,3 @@ FS_ETC=/opt/homebrew/etc/freeswitch
# Log von FreeSWITCH; der Leitstand liest daraus die Anruf-Ereignisse.
# Apple Silicon: /opt/homebrew/... , Intel-Mac: /usr/local/...
#FREESWITCH_LOG=/opt/homebrew/var/log/freeswitch/freeswitch.log
# Pipecat-Testleitung (dialog/pipecat_bootstrap.py, Nebenstelle 7501) -
# WebSocket-Ziel fuer mod_audio_stream. token= muss ASTRA_TELEFON_SECRET
# auf der Astra-Maschine entsprechen.
ASTRA_TELEFON_WS_URL=wss://astra-host.example.ts.net/telefon/inbound?token=changeme
PIPECAT_BOOTSTRAP_PORT=8095

View file

@ -1,44 +1,5 @@
# Changelog
## v1.1.0 — 2026-08-30
Branchen-Profile machen das Projekt für andere Einsatzzwecke als die
Hausarztpraxis konfigurierbar (z. B. 24h-Notdienst), dazu mehrere
Leitstand-Härtungen und Aufräumarbeiten.
### Branchen-Profile (neu)
- Projekt ist jetzt per `PROFIL`-Wahl für andere Einsatzzwecke konfigurierbar
(Praxis, Aufzug-Notdienst, …) statt hart auf die Arztpraxis zugeschnitten
- aktives Branchen-Profil im Leitstand/Dashboard sichtbar
- README-Einleitung an den Profil-Umbau angepasst
### Leitstand & Zugriff
- mehrere gleichzeitige Leitstand-Zugänge (`LEITSTAND_ZUGAENGE`), z. B. ein
eigenes Notdienst-Konto neben dem Praxis-Konto
- Abmelden-Button im Leitstand, zusätzlich per `?logoff` in der URL auslösbar
- eingeloggter Nutzer im Header sichtbar; Verlauf zeigt jetzt, wer eine Karte
verschoben hat
- Modell-Dropdown listet lokale Ollama-Modelle live statt fest im Code
- Dringlichkeitsfarben getauscht und zentralisiert; `/api/anrufe` und
`/api/verlauf` werden jetzt gecacht (Performance)
### Testzugang & Sicherheit
- signierte, 1h gültige Testzugangslinks für den Leitstand ("agent-one
test"-Kommando)
- Sicher-Modus bei Testzugang erzwungen, nicht abschaltbar
- Rufnummern bei Testzugang serverseitig maskiert statt nur per CSS geblurrt
(vorher im Rohdaten-Response sichtbar)
### Zuverlässigkeit
- Störungswache (`pipe.monitor`) + Verschlüsselung-at-rest dokumentiert
- Log-Rotation für Rohlogs + Aufräumen alter Verlauf-Archivkopien
- Linux-Setup-Hinweis in der README ergänzt
### Sonstiges
- echte Aufnahme statt Sprachsynthese als Ansage möglich (`ANSAGE_TTS=datei`)
- Nextcloud-Profil, LinkedIn und Telefonnummer (internationales Format) im
Footer
## v1.0.0 — 2026-08-25
Erste stabile Version. Läuft produktiv (mit Testdaten) auf macOS/Apple

View file

@ -1,401 +0,0 @@
# Handoff — Live-Telefonagent (laufend, zuletzt aktualisiert 2026-09-17)
## Stand in einem Satz
Astra ist live auf zwei Wegen erreichbar: direkt über Durchwahl 9 und über die Hauptnummer mit **Taste 9 während der Ansage** (Plusnet → FreeSWITCH → `dialog/pipecat_bootstrap.py` → WebSocket → Astra auf minim4-1, Modell `qwen3.5:4b`). Beides am 2026-09-17 mit echten Anrufen bestätigt. Größter offener Punkt: **Sprachqualität am Telefon ist schlecht** (noch nicht untersucht).
## 2026-09-17 (Abend) — Taste 9 auf der Hauptnummer, Astra-Fakten, Offenes
### Taste 9 während der Ansage → Astra (Commit `7c85f91`)
`00_praxis_ab.xml` spielt `telefon/ansage.wav` jetzt per
`play_and_get_digits` (Regex `^9$`, 200 ms Nachlauf) statt `playback` und
ruft danach `execute_extension hauptnummer_taste_${hauptnummer_taste} XML
hauptnummer_menue` auf. Nur `hauptnummer_taste_9` existiert dort →
`socket 127.0.0.1:8095 async full`, identisch zu DW9. Ohne Taste findet
`execute_extension` nichts und die Aufnahme startet wie bisher.
- Eigener Kontext `hauptnummer_menue` (`dialplan/hauptnummer_menue.xml`,
Top-Level wie `agentzwei.xml`), weil der Catch-all in `public` die Wahl
sonst verschluckt. Vorlage `telefon/freeswitch/hauptnummer_menue.xml.tpl`,
eingespielt von `einrichten.sh`.
- DW3 und DW9 unverändert (DW3 spielt weiter `playback`, keine Tastenwahl).
- **Andere Taste** (z. B. 5) bricht die Ansage ab und startet sofort die
Aufnahme - bewusst so gelassen, bei Bedarf ändern.
- Kein `hangup` nach `socket`: läuft der Bootstrap nicht, landet der Anrufer
auf dem Anrufbeantworter statt in der Stille.
- Verifiziert: Loopback (Taste 9 → Astra-Begrüßung, Taste 5 → Aufnahme) und
echter Anruf durch den Nutzer ("klappt super").
**Achtung beim Testen:** Jeder Loopback-Test, der bis zur Aufnahme kommt,
legt eine echte Datei in `telefon/eingang/` ab, die `pipe.watch` nach
wenigen Sekunden als Anruf verarbeitet. Sofort löschen oder vor der Aufnahme
auflegen (`uuid_kill`).
### Notruf-Ton: eingebaut und auf Wunsch wieder entfernt
Zwischendurch lief auf der Hauptnummer ein 54-s-Musikton
(`~/Downloads/MARTIN-Notruf_Ton.m4a`) vor der Ansage mit Taste 1 → Astra
(`83b576f`), dann eine Telefonband-Aufbereitung (`60b6154`, zurückgenommen
in `4e7eeab`). Der Nutzer wollte die alte Ansage zurück: komplett revertiert
in `c9bf40d`, Repo und Live-Dialplan waren danach identisch mit Tag
`vor-notruf-ton-2026-09-17` bzw. `ablage/sicherung-vor-notruf-ton-2026-09-17/`.
Erkenntnis falls es wiederkommt: der Ton ist bass-lastige Musik (Energie fast
komplett unter 300 Hz, im Telefonband nur -25 dB) und klingt am Telefon
schlecht - A-law war nicht die Ursache.
### Astra-Fakten (minim4-1)
- **Modell am Telefon: `qwen3.5:4b`** (nicht mehr `granite4.2:8b`), belegt
im Log (`'title': 'Antwort formulieren', 'detail': 'qwen3.5:4b'`) und über
`https://minim4-1.tail0f2cb2.ts.net/api/status`.
- **Latenz:** erste Antwort pro Anruf ~5,8 s, danach ~2,3 s - das Vorwärmen
beim Verbindungsaufbau reicht offenbar nicht ganz.
- **Log:** `~/Desktop/martin-voice-interface/.runtime/server.log` (Prozess
`uv run python -m astra.server`, gestartet 2026-09-17 15:29).
`/private/tmp/astra_server.log` ist seit 2026-09-13 tot.
- **SSH auf minim4-1** ist seit 2026-09-17 an ("Entfernte Anmeldung"),
Schlüssel-Login von minim4-2 funktioniert (`ssh minim4-1`).
### Offen
1. **Sprachqualität am Telefon schlecht** (Nutzer, 2026-09-17, nach Anrufen
über Taste 9) - nicht untersucht. Kandidaten: TTS (`LocalPocketTTSService`)
→ Resampling auf 8 kHz, `uuid_audio_stream`/Playback-Kette im Bootstrap,
Codec (Leitung läuft PCMA).
2. **IP-Wechsel-Erkennung** in `starten.sh` noch nie real ausgelöst (nur der
Profil-Neustart von Hand getestet).
3. **`agentzwei.xml`:** Kommentar sagt "AgentTwo begraben, register=false",
eingestellt ist `register=true` und der Trunk ist REGED - Anrufe auf DW2
laufen in einen Kontext ohne Backend. Nicht angefasst.
4. Störungs-Alarm weiterhin nur lokal auf dem Mac mini (siehe 2026-09-14).
**Diagnose-Tipp (Fehlalarm am 2026-09-17):** "DW9 landet auf der Zentrale"
war eine verwählte Hauptnummer. Welche Nummer wirklich gewählt wurde, steht
pro Anruf im FreeSWITCH-Log:
`grep -a "Regex .*\[dw9_rufagent\]" /opt/homebrew/var/log/freeswitch/freeswitch.log | tail`
- DW9 = `sip:+49210378916179@ipfonie.de`, Hauptnummer = `sip:+4921037891617@ipfonie.de`.
## 2026-09-17 — Leitung stumm nach IP-Wechsel, Watchdog-Kinder sterben
**Symptom:** Durchwahl 9 (und die ganze Leitung) nahm nicht ab. Letzter
eingehender Anruf im FreeSWITCH-Log: 2026-09-15 18:05. Trunks trotzdem
REGED/UP.
### 1. Veraltete öffentliche IP im Contact
FreeSWITCH ermittelt die externe IP per STUN nur beim Laden der XML
(`vars.xml`, `stun-set`). Contact an Plusnet war noch `9.246.125.72`,
tatsächlich `217.142.18.120` (Zwangstrennung, 05:17 kurz 408/DOWN).
Registrierung und Pings gehen weiter raus, eingehende INVITEs laufen ins
Leere - kein Fehler im Log.
**Fix:** `starten.sh` vergleicht bei jedem Watchdog-Lauf
`fs_cli -x 'stun stun.freeswitch.org'` mit `Ext-SIP-IP` des
external-Profils; bei Abweichung und 0 Gesprächen `reloadxml` +
`sofia profile external restart`. Nach einem IP-Wechsel ist die Leitung
also bis zu 5 min stumm. Profil-Neustart von Hand getestet (Trunks sofort
wieder REGED); der Abweichungsfall selbst noch nicht real ausgelöst.
### 2. launchd beendete alles, was starten.sh im Hintergrund startet
Vom Watchdog gestarteter Pipecat-Bootstrap band Port 8095 und war
Sekunden später weg, ohne Log. Ursache: launchd räumt nach Jobende die
Prozessgruppe ab. Betraf auch `pipe.monitor`/`pipe.watch`/`pipe.server` -
Fix 3 vom 2026-09-14 (Störungswache zuerst) griff unter launchd also nie.
FreeSWITCH überlebt, weil es sich selbst abkoppelt.
**Fix:** `<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**: `~/Desktop/martin-voice-interface/.runtime/server.log`
auf minim4-1 (Stand 2026-09-17; `/private/tmp/astra_server.log` ist alt).
Bei Zweifel, wohin ein laufender Prozess loggt:
`lsof -p <pid> -a -d 0,1,2`.
- **Astras aktives Modell**: `curl -s https://minim4-1.tail0f2cb2.ts.net/api/status`.
- **Hauptnummer-Menü ohne Telefon testen** (Loopback in den public-Kontext,
Taste per `uuid_recv_dtmf` auf das B-Bein; Aufnahme-Warnung oben beachten):
`fs_cli -P 8022 -x 'originate loopback/5555/public &park()'`, dann
`fs_cli -P 8022 -x "uuid_recv_dtmf <uuid von loopback/5555-b> 9"`.
- **Bootstrap-Log**: `fonagent-one/telefon/pipecat_bootstrap.log` auf
minim4-2.
- **ESL-Port** auf minim4-2 ist **8022**, nicht der Standard 8021.
- **Astra neu starten** (siehe vorherige Handoff-Fassung im Git-Log für den
vollständigen Befehl mit `ASTRA_TELEFON_SECRET`/`ASTRA_LOG_LEVEL`).
## Zugangsdaten/Config
- `.env` auf minim4-2 im Projekt (gitignored) — SIP-Zugänge für `plusnet`,
`agentzwei`, `agentzwei_sbc`, `ASTRA_TELEFON_WS_URL`, jetzt auch
`DW9_DDI=49210378916179`.
- `ASTRA_TELEFON_SECRET` — nur als Laufzeit-Env-Var gesetzt, nicht in einer
Datei auf minim4-1 abgelegt. Muss auf beiden Seiten übereinstimmen.
## GELÖST 2026-09-13 (Folgefehler): Begrüßung komplett stumm nach dem Cap-Fix
Der Cap-Fix oben (Puffer auf 12s angehoben) legte einen zweiten, tieferen Bug
frei: `TTSStoppedFrame` erreicht `serialize()` in diesem Transport **nie** -
per Frame-Type-Tracing bestätigt, `FastAPIWebsocketOutputTransport` reicht an
den Serializer nur `OutputAudioRawFrame` und (bei Barge-in) `InterruptionFrame`
durch, `TTSStoppedFrame` wird intern fürs "Bot hat aufgehört zu sprechen"
verbraucht und nie weitergereicht. Der `if isinstance(frame, TTSStoppedFrame)`-
Zweig war also von Anfang an toter Code - geflusht wurde bisher ausschließlich
über die (zu klein bemessene) Cap. Mit der korrigierten, viel höheren Cap
erreichte eine normal lange Antwort (3-4s) diese nie mehr - und ohne den
(toten) TTSStoppedFrame-Pfad flusste dann gar nichts mehr: Astra loggte
Begrüßung + Transkript ganz normal, aber bei FreeSWITCH kam kein einziges
Playback-Event an.
**Fix:** Flush jetzt über ein Inaktivitäts-Timeout (300ms ohne neuen
Audio-Frame) statt über `TTSStoppedFrame`. Da der Idle-Flush aus einem
Hintergrund-Task feuert (nicht aus `serialize()`s Rückgabewert), braucht er
einen eigenen Sendeweg - der Serializer nimmt jetzt zusätzlich zu `notify`
ein `send`-Callable (`websocket.send_text`). Commit `0a0c8d5` in
`martin-voice-interface`.
Live verifiziert (Loopback-Test): 2,88s gesendet, FreeSWITCH spielt 3,02s
vollständig ab ("done playing file") - keine Stille, kein Abschneiden mehr.
## GELÖST 2026-09-13 (weitere Folgefehler): zu lange Antworten + hackige Wiedergabe
Nach dem Idle-Flush-Fix zwei weitere Probleme live gemeldet:
1. **Antworten zu lang** - der Prompt allein ("1-2 kurze Sätze") wurde vom
Modell ignoriert, Antworten liefen auf 2-3 Sätze und lang genug, um die
12s-Puffergrenze zu reißen. Fix: harte `num_predict`-Grenze
(`Settings.telefon_max_tokens`, Standard 40, `ASTRA_TELEFON_MAX_TOKENS`
env-einstellbar) statt nur einer Bitte im Prompt. Commit `4e6f266`.
2. **Wiedergabe "hackig"** - der 300ms-Idle-Flush (siehe vorheriger Eintrag)
feuerte bei Mehrsatz-Antworten fast immer VOR dem nächsten Satz, weil die
Pause zwischen zwei TTS-Satz-Chunks (das LLM muss den nächsten Satz erst
zu Ende streamen) regelmäßig über 300ms liegt - jeder Satz kam so als
eigene, einzeln abgespielte Nachricht statt als eine zusammenhängende
Antwort. Fix: `flush_now()` auf dem Serializer, ausgelöst über
`run_telephony_call`s `notify()`-Hook genau dann, wenn die Pipeline
meldet, dass der Bot wirklich fertig gesprochen hat
(`BotStoppedSpeakingFrame` via den `'state': 'listening'`-Callback) - das
ist das einzige verlässliche "Antwort fertig"-Signal, da `TTSStoppedFrame`
selbst `serialize()` nie erreicht (siehe voriger Eintrag). Der
Idle-Timeout ist jetzt nur noch Fallback (4s statt 300ms). Commit
`42acca4`.
Live verifiziert: Begrüßung flusht jetzt ~2ms nach echtem Sprechende (statt
vorher 300ms Verzögerung), vollständig, keine Aufteilung.
**Offen für nächsten Test:** ob Mehrsatz-Antworten jetzt tatsächlich flüssig
klingen (nur mit Loopback getestet, das produziert keine echte Sprache für
einen mehrteiligen Dialog) - braucht einen echten Anruf mit echtem Gespräch.
## GELÖST 2026-09-13 (weiterer Folgefehler): Anruf trennt nach ~50s ohne Antwort
Erster echter Mehrsatz-Dialog lief mehrere Runden gut (kurze Antworten,
zusammenhängende Wiedergabe) - dann zweimal hintereinander: VAD hat ausgelöst
(`min_volume=0.3` reagiert absichtlich auch auf leise Signale, siehe
vorheriger Eintrag), aber Nemotron hat für die ganze Runde nichts
transkribiert (`Nemotron result: final, 0 characters`). Ein leeres Transkript
erreicht `LLMUserAggregator` nie als echte Nutzer-Nachricht - keine
LLM-Anfrage, keine Antwort, keine Rückmeldung. Für den Anrufer wirkt das wie
eine tote Leitung; nach zwei solchen Runden hat er aufgelegt.
**Fix:** `NemotronSTTService` bekommt jetzt optional `no_speech_phrases`
(`astra.services.NO_SPEECH_PHRASES`, z. B. "Wie bitte?", "Kannst du das
wiederholen?") und spricht bei leerem finalem Transkript eine davon über
`TTSSpeakFrame`. Nur für die Telefonie verdrahtet (Browser hat schon einen
sichtbaren "listening"-Zustand). Commit `c8946d0`.
**Noch offen:** ob `min_volume=0.3` grundsätzlich zu locker ist (löst zu oft
auf Nicht-Sprache aus) - dieser Fix behandelt nur das Symptom (Anrufer bekommt
immer eine Reaktion), nicht die Ursache. Bei wiederholtem Auftreten:
`ASTRA_TELEFON_VAD_MIN_VOLUME` schrittweise erhöhen und beobachten, ob echte
leise Sprache dann noch erkannt wird.

155
README.md
View file

@ -1,31 +1,30 @@
# Logpy:AgentOne
**v1.1.0** — siehe [CHANGELOG.md](CHANGELOG.md)
**v1.0.0** — siehe [CHANGELOG.md](CHANGELOG.md)
Anruf-Intake-Agent — nimmt Anrufe (Sprachnachrichten) entgegen, **transkribiert**
Praxis-Telefon-Agent — nimmt Anrufe (Sprachnachrichten) einer Hausarztpraxis entgegen, **transkribiert**
sie lokal, **kategorisiert** das Anliegen und **legt** das Ergebnis strukturiert
ab (lokal und optional in Nextcloud). Für welchen Einsatzbereich (Hausarztpraxis,
24h-Notdienst, …) ist reine Konfiguration — siehe
[Branchen-Profil](#kategorien--branchen-profil). Zwei Beispiele liegen bei:
`praxis` (Hausarztpraxis) und `aufzug-notdienst`.
ab (lokal und optional in Nextcloud).
**Vollständig on-prem** — keine Anrufdaten verlassen den Rechner.
**Vollständig on-prem** — keine Patientendaten verlassen den Rechner.
![Der Leitstand im Dunkelmodus: Dienste-Ampeln und Verlauf oben, darunter das Board mit Eingang, In Bearbeitung, Rückfragen und Erledigt](docs/agentone-screen-1.jpg)
> **Hinweis:** Alle Namen, Rufnummern und Anliegen in diesem Repository sind
> erfunden — auch im Screenshot und in der Beispielkonfiguration. „Praxis
> Musterhausen" ist ein Platzhalter. Echte Zugangsdaten und Anrufdaten liegen
> außerhalb des Repositorys (`.env`, `ablage/`, `telefon/`).
Wer das Projekt auf einem weiteren Rechner einrichtet: [docs/MITGABE.md](docs/MITGABE.md)
sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen.
## Stufen
1. **Die Pipe** (fertig): Audio → Transkript → Kategorie → Ablage.
2. **Telefonie** (steht): FreeSWITCH nimmt über den Plusnet-Trunk an, spielt die
Ansage, nimmt auf und legt die Datei im Eingang der Pipe ab.
3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅,
Verschlüsselung at rest ✅ (Betreiber-Pflicht: FileVault, siehe
[Datenschutz](#datenschutz-dsgvo)), Monitoring/Alerting bei Diensteausfall ✅
(`pipe/monitor.py`, siehe unten).
3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅ — offen:
Monitoring/Alerting bei Diensteausfall, Verschlüsselung at rest.
## Bausteine
@ -38,62 +37,19 @@ ab (lokal und optional in Nextcloud). Für welchen Einsatzbereich (Hausarztpraxi
| `pipe/config.py` | Konfiguration aus `.env` |
| `pipe/watch.py` | Beobachtet den Eingang und schiebt Aufnahmen durch die Pipe |
| `pipe/server.py` | Leitstand: Live-Ansicht mit Status, Anrufen und Verlauf |
| `pipe/dienste.py` | Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung, Nextcloud) — geteilt von Leitstand und Störungswache |
| `pipe/monitor.py` | Störungswache: alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt |
| `pipe/alarm.py` | Verschickt den lokalen macOS-Alarm (osascript) |
| `pipe/protokoll.py` | Ereignisprotokoll (JSONL) für den Leitstand |
| `pipe/audit.py` | Unveränderliches Audit-Log (append-only, siehe unten) |
| `pipe/loeschen.py` | Löscht erledigte Anrufe nach Ablauf der Aufbewahrungsfrist |
| `pipe/bewertung.py` | 👍/👎-Bewertung pro Anruf (Leitstand) |
| `pipe/export.py` | Exportiert bewertete Anrufe als JSONL-Trainingsdaten |
| `pipe/kategorien.py` | Lädt Kategorien-Schema des aktiven Branchen-Profils (siehe unten) |
| `profile/<name>/` | Branchen-Profil: Prompt, Kategorien, Sicherheitsnetz, Ansage |
| `telefon/` | FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte |
| `prompts/categorize_de.txt` | Kategorisierungs-Prompt (deutsch, mehrsprachig-tauglich) |
| `telefon/` | Ansage, FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte |
## Kategorien & Branchen-Profil
## Kategorien
Alles, was fachlich vom Einsatzzweck abhängt, steckt in einem **Branchen-Profil**
unter `profile/<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.
`termin · rezept · ueberweisung · befund · verwaltung · beschwerden · notfall ·
rueckruf · sonstiges`
Dringlichkeit: `niedrig · normal · hoch · notfall`
## Einrichtung
@ -180,7 +136,7 @@ eingehende Anrufe an, spielt die Ansage und nimmt auf. Die Aufnahme landet unter
sie durch die Pipe und räumt sie nach `telefon/verarbeitet/` weg.
```bash
./telefon/ansage_bauen.sh # Ansage aus profile/$PROFIL/ansage.txt erzeugen
./telefon/ansage_bauen.sh # Ansage aus telefon/ansage.txt erzeugen
./telefon/freeswitch/einrichten.sh # Trunk + Dialplan in FreeSWITCH eintragen
./telefon/starten.sh # startet FreeSWITCH, Verarbeitung, Leitstand
```
@ -236,37 +192,7 @@ Der SIP-Zugang steht in der `.env` (`SIP_USER`, `SIP_DOMAIN`, `SIP_PASS`). Das
Einrichtungsskript trägt das Passwort in die FreeSWITCH-Konfiguration ein und
setzt deren Rechte auf 600 — im Git liegen nur Vorlagen mit Platzhaltern.
### Durchwahl parallel zu einem Endgerät klingeln lassen
Manche Provider (z. B. Fonial/Plusnet) klingeln bei einer Durchwahl **parallel**
an allen dafür registrierten Zielen — bei uns also gleichzeitig an AgentOne und
an einem echten Telefon/Softphone. Ohne Gegenmaßnahme gewinnt AgentOne das
Rennen aber immer: `answer()` ist im Dialplan die erste Aktion, es gibt keine
Verzögerung davor. Das Telefon klingelt dann faktisch nie, weil der Anruf schon
beim Assistenten gelandet ist, bevor am Gerät überhaupt ein Klingelzeichen
ankommt.
`DW3_DDI` in der `.env` schaltet eine Wartezeit vor `answer()` frei
(`telefon/freeswitch/dw3_warten.xml.tpl`, deployed als `00_dw3_warten.xml`
lädt alphabetisch vor `00_praxis_ab.xml`): erkennt der Dialplan die passende
Durchwahl, wartet er 20 Sekunden, bevor überhaupt abgenommen wird. Nimmt in der
Zeit jemand am registrierten Endgerät ab, bricht der Provider den Versuch bei
AgentOne per SIP-`CANCEL` ab — der Dialplan kommt gar nicht mehr bis zum
`answer()`. Nimmt niemand ab, läuft er nach den 20 Sekunden normal weiter zu
Ansage und Aufnahme, identisch zum Standard-Anrufbeantworter.
Wichtig: die Durchwahl selbst kommt bei diesem Trunk nicht im
`destination_number` an (der zeigt bei Plusnet nur eine interne Trunk-Kennung,
unabhängig von der gewählten Nummer), sondern im SIP-Header
`X-ORIGINAL-DDI-URI` der eingehenden Einladung — nur der ist brauchbar, um DW3
von anderen Anrufen zu unterscheiden. `DW3_DDI` erwartet die volle Rufnummer
inklusive Durchwahl-Ziffer(n), nur Ziffern, kein `+`.
Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim
Anrufbeantworter wie bisher.
Text ändern: `profile/$PROFIL/ansage.txt` bearbeiten (siehe
[Branchen-Profil](#kategorien--branchen-profil)), `ansage_bauen.sh` erneut
Text ändern: `telefon/ansage.txt` bearbeiten, `ansage_bauen.sh` erneut
ausführen. Für den Praxisbetrieb muss die Ansage den Notruf 112 nennen und auf
die Aufzeichnung hinweisen, bevor der Signalton kommt — beides ist bei einer
Arztpraxis Pflicht, siehe „Offener Punkt" unter Datenschutz weiter unten.
@ -321,29 +247,6 @@ in `telefon/audit.log` — anders als `verlauf.log` (fürs Leitstand-UI, wird au
zusätzlich per macOS `chflags uappnd` gegen nachträgliches Ändern/Kürzen
abgesichert (auch der eigene Prozess kommt danach nur noch per Anhängen rein).
## Störungswache (Monitoring/Alerting)
Die Dienste-Ampel im Leitstand (Telefon, Verarbeitung, Spracherkennung,
Nextcloud) zeigt den Zustand nur an, wenn gerade jemand die Seite offen hat.
`pipe/monitor.py` läuft deshalb als **eigener Prozess** (von
`telefon/starten.sh` mitgestartet, Log `telefon/monitor.log`), fragt dieselbe
Ampel (`pipe/dienste.py`) alle `MONITOR_TAKT_S` Sekunden (Default 60) ab und
alarmiert bei einer echten Störung (`schlecht`/`aus` — nicht bei `unklar`,
z. B. Nextcloud unkonfiguriert) lokal per macOS-Systembenachrichtigung + Ton
(`pipe/alarm.py`, `osascript`). Solange die Störung anhält, wird alle
`MONITOR_WIEDERHOLUNG_MIN` Minuten (Default 15) erneut erinnert; bei
Wiederherstellung einmalig „wieder ok".
Bewusst ein eigener, dritter Prozess statt in `pipe.watch` oder `pipe.server`
eingebaut: fällt einer der beiden aus, kann die Wache das trotzdem noch
melden. Wirkt nur lokal auf diesem Mac — für Alarme aufs Handy (z. B. per
Push/E-Mail) `pipe/alarm.py` erweitern.
```bash
python3 -m pipe.monitor # dauerhaft beobachten (normalerweise per starten.sh)
python3 -m pipe.monitor --einmal # einmal prüfen, für Tests
```
## Bewertung & Trainingsdaten-Export
Jede Karte im Leitstand hat 👍/👎 — war die Kategorisierung richtig? Bei 👎
@ -368,21 +271,7 @@ Gesundheitsdaten — Pflichten für den Produktivbetrieb (Stufe 2):
- Löschfristen — siehe oben (`LOESCHFRIST_TAGE`).
- Verarbeitung lokal (Whisper + Qwen) — kein Cloud-Dienst im Datenpfad.
> **Offener Punkt:** Die aktuelle `profile/praxis/ansage.txt` erfüllt die
> ersten beiden Punkte noch nicht (kein Notfall-Hinweis, keine
> Aufnahme-Einwilligung) — Text anpassen und `ansage_bauen.sh` neu laufen
> lassen, bevor die Anlage im echten Praxisbetrieb ans Netz geht.
> **Verschlüsselung at rest — Pflicht des Betreibers:** Die Pipe legt
> Aufnahmen, Transkripte und Metadaten (`ablage/`, `telefon/`, `training/`)
> unverschlüsselt auf der Platte ab und verlässt sich auf
> Festplattenverschlüsselung durch das Betriebssystem. **FileVault muss auf
> jedem Rechner aktiviert sein, der diese Pipe betreibt**
> (Systemeinstellungen → Datenschutz & Sicherheit → FileVault, oder
> `sudo fdesetup enable`) — den Recovery-Key sicher verwahren (Passwort-
> Tresor), da er das einzige Mittel bei vergessenem Login-Passwort ist.
> Ist FileVault auf einem Zielsystem nicht aktivierbar oder nicht zulässig
> (z. B. Mehrbenutzer-Rechner, andere Auflagen), reicht das nicht aus — dann
> braucht die App eine eigene Verschlüsselung der Ablage, die es aktuell
> **nicht** gibt. Das Aktivieren/Prüfen von FileVault ist Aufgabe des
> Betreibers vor Ort, nicht Teil dieses Repos.
> **Offener Punkt:** Die aktuelle `telefon/ansage.txt` erfüllt die ersten
> beiden Punkte noch nicht (kein Notfall-Hinweis, keine Aufnahme-Einwilligung)
> — Text anpassen und `ansage_bauen.sh` neu laufen lassen, bevor die Anlage im
> echten Praxisbetrieb ans Netz geht.

View file

View file

@ -1,68 +0,0 @@
"""Outbound-Call-Trigger fuer die Pipecat-Testleitung.
Ruft eine Nummer ueber den Plusnet-Trunk an und uebergibt sie nach Abheben an
dieselbe Bruecke wie die interne Testnebenstelle 7501
(dialog/pipecat_bootstrap.py, Port 8095) - answer()+uuid_audio_stream+park()
laeuft dort identisch, unabhaengig davon ob der Anruf rein- oder rausgeht. Der
Dialplan-Umweg (Nebenstelle 7501) entfaellt hier: `&socket(...)` haengt den
ausgehenden Kanal direkt nach Abheben an den Bootstrap, ohne Dialplan-Lookup.
Nutzung: python3 -m dialog.anrufen <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()

View file

@ -1,187 +0,0 @@
"""Outbound-ESL bootstrap for the Pipecat phone-agent test line.
FreeSWITCH's dialplan extension "agentzwei_pipecat_test" (destination_number
7501, dialplan/default/06_agentzwei_pipecat_test.xml, internal test-only, not
reachable from the PSTN) - and now also dialog/anrufen.py's outbound calls -
hand the channel here via `socket(127.0.0.1:PORT async full)`. This process's
job: answer(), tell mod_audio_stream to start streaming the call's audio to
Astra's /telefon/inbound, then stay on the line for as long as the call lasts
to actually play back what Astra sends - the conversation logic itself lives
entirely in Astra's Pipecat pipeline, over the WebSocket that mod_audio_stream
opens outbound to Astra.
mod_audio_stream has no dialplan application, only the ESL API command
`uuid_audio_stream` (confirmed against the module's own source and by
testing). Confirmed by testing (2026-09-12): issuing `uuid_audio_stream` from
this same process - even over a second, separate ESL connection - reliably
fails the WebSocket connect for real external/gateway calls (instant
"connection error", no such issue for the loopback test line). Matches a
known mod_audio_stream issue (amigniter/mod_audio_stream#92): "It's an issue
with using uuid_audio_stream on the same call session uuid as the Python
script that's listening for the WebSocket, separating these processes
resolves the problem." So the command is shelled out to `fs_cli` as a
genuinely separate OS process, not issued from within this one at all.
Second gap found by testing (2026-09-13): mod_audio_stream does NOT play
audio back on its own. Astra's `streamAudio` message only makes it write a
temp WAV file and fire a `mod_audio_stream::play` CUSTOM event containing that
path (audio_streamer_glue.cpp:290-303) - something has to catch that event and
actually play the file. Nothing in the module or the dialplan does this, so
this process stays on the call (instead of returning right after park()) and
plays each file back as the events arrive, until the call hangs up - with the
`playback` application over this same outbound socket. Unlike uuid_audio_stream
that is not an "api" command but an execute-style application call, exactly
like the answer()/park() that already work here, so it does not need the
separate-process detour.
Getting those CUSTOM events delivered at all took two subscriptions plus one
workaround; see run() and event_body_json() for the details.
"""
import json
import logging
import os
import subprocess
import sys
import gevent
import greenswitch
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("pipecat_bootstrap")
BIND_ADDRESS = os.environ.get("PIPECAT_BOOTSTRAP_BIND", "127.0.0.1")
BIND_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095"))
ASTRA_WS_URL = os.environ.get("ASTRA_TELEFON_WS_URL", "")
STREAM_MIX_TYPE = "mono"
STREAM_SAMPLE_RATE = "16k" # matches NemotronSTTService's hard 16 kHz requirement on the Astra side
# Upper bound so a greenlet can never outlive a call FreeSWITCH itself forgot
# to tell us ended - failure mode is "one stray idle greenlet", not a runaway.
MAX_CALL_SECONDS = 30 * 60
FS_HOST = "127.0.0.1"
FS_PORT = os.environ.get("FS_PORT", "8022")
FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon")
FS_CLI = os.environ.get("FS_CLI_BIN", "/opt/homebrew/bin/fs_cli")
def fs_cli(command: str) -> str:
"""Run one `api` command via `fs_cli` as its own OS process - see module
docstring for why commands for this call must not go over this process's
own outbound-socket connection."""
result = subprocess.run(
[FS_CLI, "-H", FS_HOST, "-P", FS_PORT, "-p", FS_ESL_PASSWORD, "-x", command],
capture_output=True, text=True, timeout=10,
)
return (result.stdout or result.stderr).strip()
def event_body_json(event) -> dict | None:
"""Read a FreeSWITCH event's body (the part after the blank line) as JSON.
greenswitch's ESLEvent.parse_data() only knows "key: value" lines, so an
event body - mod_audio_stream sends bare JSON, which has no ": " in it -
is never split off as its own field. It ends up appended to the value of
whatever header came last, which in plain format is always the body's own
"Content-Length". So `headers["Content-Length"]` reads e.g.
'84\\n\\n{"audioDataType":"wav","file":"/tmp/....wav"}' - the payload is
there, just glued to a number. Cut at the first "{" and parse that.
"""
raw = event.headers.get("Content-Length") or ""
start = raw.find("{")
if start == -1:
return None
try:
payload = json.loads(raw[start:])
except (ValueError, TypeError):
return None
return payload if isinstance(payload, dict) else None
class PipecatBridge:
"""One instance per call, per greenswitch's OutboundESLServer contract."""
def __init__(self, session):
self.session = session
self.call_id = getattr(session, "uuid", "?")
self.hung_up = gevent.event.Event()
def on_play_event(self, event):
payload = event_body_json(event)
if payload is None:
logger.warning(
"Anruf %s: mod_audio_stream::play-Event ohne lesbaren JSON-Rumpf: %r",
self.call_id, event.headers,
)
return
path = payload.get("file")
if not path:
logger.warning("Anruf %s: play-Event ohne Dateipfad: %r", self.call_id, payload)
return
# Same mechanism Logpy-AgentOne already uses successfully in
# production (dialplan `playback` app) - over this session's own
# connection, unlike uuid_audio_stream this is an "execute"-style
# application call (like answer()/park(), already proven to work
# here), not an "api" command.
self.session.playback(path, block=False)
logger.info("Anruf %s: spiele %s ab", self.call_id, path)
def on_hangup(self, event):
self.hung_up.set()
def run(self):
try:
# Two separate subscriptions, both required, in this order:
#
# `myevents` turns on this session's own event feed, but it does
# NOT mean "all events" - mod_event_socket hardcodes a whitelist
# of ~23 event ids there (mod_event_socket.c, parse_command(),
# "myevents" branch), all of them CHANNEL_*/DTMF/TALK. CUSTOM is
# not in it, so no module's CUSTOM event can ever arrive on a
# myevents-only socket, no matter its subclass.
#
# `event plain CUSTOM <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()

129
docs/MITGABE.md Normal file
View file

@ -0,0 +1,129 @@
# Mitgabe für die Einrichtung auf einem zweiten Rechner
Diese Datei ergänzt README (Einrichtung) und HANDOFF (Stand). Sie enthält das,
was auf der anderen Seite nicht im Repository ankommt: die Erfahrungswerte aus
dem ersten Volltest, die Stellen, an denen die Vorlagen unvollständig sind, und
die Reihenfolge, in der man prüft, ob es wirklich läuft.
## 1. Was im Repo *nicht* liegt
| Fehlt | Woher |
|---|---|
| `.env` | aus `.env.example` kopieren, Werte eintragen (Abschnitt 3) |
| Whisper-Modell (~1,6 GB) | `~/whisper-models/ggml-large-v3-turbo.bin`, Download siehe README |
| Ollama-Modell (~5,2 GB) | `ollama pull qwen3:8b` |
| Piper-Stimme | `~/piper-voices/…`, nur für die Ansage (Stufe 2) nötig |
| `ablage/`, `telefon/eingang/`, `dashboard.html` | Anruf- und Patientendaten, bleiben absichtlich lokal |
| FreeSWITCH-Konfiguration mit echtem SIP-Passwort | `telefon/freeswitch/einrichten.sh` schreibt sie aus der `.env` |
Alle Namen, Rufnummern und Beispieldaten im Repo sind erfunden. „Praxis
Musterhausen" ist Platzhalter — auch im Screenshot.
## 2. Der kurze Weg: Stufe 1 ohne Telefonie
Telefonie (FreeSWITCH, SIP-Trunk) ist für die Erprobung nicht nötig und macht
die meiste Arbeit. Für einen ersten Durchlauf reicht:
```bash
brew install ffmpeg whisper-cpp
brew install ollama && ollama serve &
ollama pull qwen3:8b
mkdir -p ~/whisper-models && cd ~/whisper-models
curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin
cd ~/praxis-telefon-agent && cp .env.example .env
python3 -m pipe.process_call test/rezept.wav --nummer 021031234567
```
Läuft das durch (~25 s), liegt das Ergebnis unter
`ablage/JJJJ-MM-TT/HH-MM-SS_021031234567/`. Nextcloud, Deck, Leitstand und
Telefonie sind alle abschaltbar und kommen erst danach dran.
## 3. Was die `.env.example` verschweigt
Die Vorlage deckt nicht alle Schalter ab, die `pipe/config.py` liest. Die
Voreinstellungen greifen still — mit diesen Folgen:
| Variable | Voreinstellung | Warum das wichtig ist |
|---|---|---|
| `STT_BACKEND` | `whispercpp` | `WHISPER_MODELL`/`WHISPER_COMPUTE` aus der Vorlage gelten **nur** für `faster`. In der Voreinstellung werden sie ignoriert. |
| `WHISPERCPP_MODELL` | `~/whisper-models/ggml-large-v3-turbo.bin` | Liegt das Modell woanders, bricht die Erkennung ab. |
| `WHISPERCPP_BIN` | `whisper-cli` | Muss im `PATH` liegen (Homebrew `whisper-cpp`). |
| `WHISPER_THREADS` | `8` | Auf kleineren Maschinen herunterdrehen. |
| `NEXTCLOUD_DECK` | *aus* | **Häufigste Verwirrung:** `DECK_TEILEN` und `DECK_STAPEL` stehen in der Vorlage, der Hauptschalter nicht. Ohne `NEXTCLOUD_DECK=1` entsteht kein Board. |
| `NEXTCLOUD_USERID` | = `NEXTCLOUD_USER` | Bei E-Mail-Login weicht die interne User-ID ab, dann zeigt der WebDAV-Pfad ins Leere. |
| `FREESWITCH_LOG` | `/opt/homebrew/var/log/freeswitch/…` | Apple-Silicon-Homebrew. Auf Intel-Macs `/usr/local/...`. Sonst bleiben die Telefon-Ereignisse im Leitstand leer. |
| `ABLAGE_LOKAL`, `TELEFON_ORDNER` | im Projektordner | Für eine Ablage auf einem anderen Volume hier umbiegen. |
| `WHISPER_PROMPT` | Praxis-Kontext | Steuert, wie zuverlässig Namen und Rufnummern erkannt werden. Bei anderem Einsatzzweck anpassen. |
`starten.sh` nutzt außerdem `FS_ETC` (Voreinstellung `/opt/homebrew/etc/freeswitch`)
und `FS_PORT` (`8099`, weil 8021 auf dem Ursprungsrechner belegt war). Auf einer
frischen Maschine ist 8021 meist frei — dann entweder `FS_PORT=8021` setzen oder
den Port in `autoload_configs/event_socket.conf.xml` auf 8099 lassen. Beides
muss zusammenpassen, sonst antwortet `fs_cli` nicht.
## 4. Prüfleiter — in dieser Reihenfolge
1. **Erkennung:** `python3 -m pipe.process_call test/termin.wav --nummer 0…`
`meta.json` enthält ein plausibles `transkript`.
2. **Kategorisierung:** `test/rezept.wav``kategorie: rezept`,
`test/notfall.wav``dringlichkeit: notfall`. Bleibt `kategorie` leer,
läuft Ollama nicht oder das Modell fehlt.
3. **Name und Rückrufnummer** stehen als eigene Felder in `meta.json` — nicht
nur im Fließtext von `anliegen_kurz` (siehe Falle 1 unten).
4. **Nextcloud:** Zugangsdaten eintragen, erneut einen Anruf verarbeiten, dann
`python3 -m pipe.resync` — meldet 0 Fehler.
5. **Deck:** `NEXTCLOUD_DECK=1` **und** `DECK_TEILEN` setzen. Ohne Freigabe legt
die Pipe Karten an, die Praxis sieht ein leeres Board.
6. **Leitstand:** `python3 -m pipe.server`, dann <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.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

View file

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

View file

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

View file

@ -83,21 +83,9 @@ OPENROUTER_KEY = os.environ.get("OPENROUTER_KEY", "")
# hinterlegten (siehe pipe/modellwahl.py) - leer lassen wenn nicht gebraucht.
OPENROUTER_MODELL = os.environ.get("OPENROUTER_MODELL", "")
# --- Branchen-Profil -----------------------------------------------------
# Buendelt alles, was fachlich vom Einsatzzweck abhaengt (Kategorien, Prompt,
# Notfall-Sicherheitsnetz, Ansage) an einer Stelle - siehe profile/<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"
PROMPT_DATEI = WURZEL / "prompts" / os.environ.get(
"PROMPT_DATEI", "categorize_de.txt"
)
# --- Ablage ------------------------------------------------------------------
ABLAGE_LOKAL = Path(os.environ.get("ABLAGE_LOKAL", str(WURZEL / "ablage")))
@ -117,28 +105,6 @@ LEITSTAND_PORT = int(os.environ.get("LEITSTAND_PORT", "8088"))
# auf den Seiten stehen Transkripte und Aufnahmen von Patienten.
LEITSTAND_USER = os.environ.get("LEITSTAND_USER", "praxis")
LEITSTAND_PASS = os.environ.get("LEITSTAND_PASS", "")
# Weitere Konten zusaetzlich zum obigen (z.B. ein eigenes Konto fuer den
# Notdienst neben dem Praxis-Konto) - kommagetrennt "nutzer:passwort",
# z.B. "notdienst:Berg,spaeter:nochwas".
LEITSTAND_ZUGAENGE = os.environ.get("LEITSTAND_ZUGAENGE", "")
def leitstand_konten() -> dict[str, str]:
"""Alle gueltigen Leitstand-Logins als {nutzername: passwort}."""
konten = {}
if LEITSTAND_PASS:
konten[LEITSTAND_USER] = LEITSTAND_PASS
for eintrag in LEITSTAND_ZUGAENGE.split(","):
nutzer, _, passwort = eintrag.strip().partition(":")
if nutzer and passwort:
konten[nutzer] = passwort
return konten
# Geteiltes Geheimnis mit dilles-agent.php ("agent-one test"-Kommando auf
# dillenberg.net) fuer signierte, 1h gueltige Testzugangslinks - siehe
# pipe.testzugang. Leer lassen deaktiviert das Feature (kein Fallback-Wert,
# ein leeres Secret darf niemals gueltige Signaturen erzeugen).
TESTZUGANG_SECRET = os.environ.get("TESTZUGANG_SECRET", "")
def leitstand_oeffentlich() -> bool:
@ -196,19 +162,6 @@ def nextcloud_aktiv() -> bool:
return bool(NEXTCLOUD_URL and NEXTCLOUD_USER and NEXTCLOUD_PASS)
# --- Störungswache (pipe.monitor) --------------------------------------------
# Prueft die Dienste-Ampel (pipe.dienste: Telefon, Verarbeitung, Spracherkennung,
# Nextcloud) periodisch und alarmiert lokal per macOS-Benachrichtigung + Ton,
# wenn niemand auf den Leitstand schaut. Eigener Prozess (siehe
# telefon/starten.sh) - faellt pipe.watch oder pipe.server aus, meldet die
# Wache das trotzdem noch.
MONITOR_TAKT_S = int(os.environ.get("MONITOR_TAKT_S", "60") or "60")
# Erinnerungsabstand, waehrend eine Stoerung anhaelt - sonst nur ein einziger
# Alarm beim Ausfall, der leicht untergeht.
MONITOR_WIEDERHOLUNG_MIN = int(os.environ.get("MONITOR_WIEDERHOLUNG_MIN", "15") or "15")
# macOS-Systemton (siehe /System/Library/Sounds), der bei jedem Alarm abgespielt wird.
MONITOR_TON = os.environ.get("MONITOR_TON", "Sosumi").strip() or "Sosumi"
# --- Aufbewahrungsfrist (pipe.loeschen) --------------------------------------
# Nach so vielen Tagen wird ein Anruf-Ordner (Aufnahme + Transkript + Meta)
# geloescht - lokal und, falls hochgeladen, auch bei Nextcloud. 0 = aus (nichts

View file

@ -11,11 +11,15 @@ import json
import sys
from pathlib import Path
from . import config, kategorien
from . import config
RANG = kategorien.DRINGLICHKEIT_RANG
FARBE = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()}
KAT_LABEL = kategorien.KATEGORIE_LABEL
RANG = {"notfall": 0, "hoch": 1, "normal": 2, "niedrig": 3}
FARBE = {"notfall": "#E9322D", "hoch": "#E0A339", "normal": "#C9A227", "niedrig": "#31CC7C"}
KAT_LABEL = {
"termin": "Termin", "rezept": "Rezept", "ueberweisung": "Überweisung",
"befund": "Befund", "verwaltung": "Verwaltung", "beschwerden": "Beschwerden", "notfall": "Notfall",
"rueckruf": "Rückruf", "sonstiges": "Sonstiges",
}
def _anrufe() -> list[dict]:
@ -61,7 +65,7 @@ def baue(ausgabe: Path) -> Path:
karten = "\n".join(_karte(d) for d in anrufe)
doc = f"""<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anruf-Übersicht · {html.escape(kategorien.PROFIL_NAME)}</title>
<title>Anruf-Übersicht Praxis Musterhausen</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}}}}
@ -82,7 +86,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 · {html.escape(kategorien.PROFIL_NAME)}</h1>
<h1>Anruf-Übersicht</h1>
<p class="sub">{len(anrufe)} Anrufe · {zusammenfassung or ''} · lokal erzeugt aus der Ablage</p>
{karten}
</div></body></html>"""

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -1,35 +0,0 @@
"""Signierte, zeitlich begrenzte Testzugangs-Links zum Leitstand.
Gegenstueck zu dilles_agent_one_test_command() in dillenberg.net's
dilles-agent.php ("agent-one test"-Kommando im Alice-Chat). Beide Seiten
teilen sich TESTZUGANG_SECRET (config.py) - der Link wird komplett auf der
PHP-Seite erzeugt, ohne Live-Aufruf hierher; hier wird nur die Signatur
geprueft. Stateless: kein Server-seitiger Speicher fuer ausgegebene Tokens,
die Gueltigkeit steckt komplett im signierten Ablauf-Zeitstempel.
Token-Format: "<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,18 +152,10 @@ def main(argv: list[str] | None = None) -> int:
try:
while True:
durchlauf(eingang)
if time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S:
if config.LOESCHFRIST_TAGE:
anzahl = loeschen.laeuft()
if anzahl:
print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.")
# Verlauf-Archivkopien (pipe.protokoll, "Tag archivieren")
# sammeln sich sonst unbegrenzt an - unabhaengig von
# LOESCHFRIST_TAGE, das nur Anrufe betrifft, nicht Log-Kopien.
archiv_geloescht = protokoll.raeume_archiv_auf()
if archiv_geloescht:
print(f"🗑 {archiv_geloescht} alte Verlauf-Archivkopie(n) geloescht "
f"(> {protokoll.ARCHIV_FRIST_TAGE} Tage).")
if config.LOESCHFRIST_TAGE and time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S:
anzahl = loeschen.laeuft()
if anzahl:
print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.")
letzter_loeschlauf = time.monotonic()
time.sleep(TAKT_S)
except KeyboardInterrupt:

Binary file not shown.

View file

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

View file

@ -1,12 +0,0 @@
{
"profil_name": "Aufzug-Notdienst",
"kategorien": ["eingeschlossen", "stoerung", "wartung", "rueckruf", "sonstiges"],
"kategorie_labels": {
"eingeschlossen": "Person eingeschlossen",
"stoerung": "Störung",
"wartung": "Wartung",
"rueckruf": "Rückruf",
"sonstiges": "Sonstiges"
},
"sicherheitsnetz_kategorie": "eingeschlossen"
}

View file

@ -1,44 +0,0 @@
Du bist der Anruf-Assistent eines 24-Stunden-Notdienstes für Aufzugsanlagen.
Du bekommst das Transkript einer Sprachnachricht, die ein Anrufer auf den
Anrufbeantworter gesprochen hat. Deine Aufgabe: das Anliegen sachlich
erfassen und einordnen.
WICHTIG:
- Du gibst KEINE technischen Anweisungen und versuchst nicht, das Problem
selbst zu lösen (z. B. "versuchen Sie die Tür aufzudrücken").
- Du erfindest nichts. Fehlt eine Angabe im Transkript, setze null.
- Fasse neutral und knapp zusammen, in der dritten Person.
- Das Transkript kann fehlerhaft sein (automatische Spracherkennung). Interpretiere wohlwollend.
- Antworte AUSSCHLIESSLICH mit dem geforderten JSON-Objekt, ohne weiteren Text.
Kategorien (Feld "kategorie", genau einer dieser Werte):
- "eingeschlossen" : akut jemand im Aufzug eingeschlossen/eingeklemmt, kann
nicht selbst raus
- "stoerung" : Aufzug ausser Betrieb, meldet Fehler, ungewöhnliche
Geräusche o. Ä. - aber NIEMAND eingeschlossen
- "wartung" : Wartungs-/Inspektionstermin vereinbaren, Rückfrage zu
Vertrag/Rechnung
- "rueckruf" : bittet um Rückruf ohne klar anderes Anliegen
- "sonstiges" : passt in keine der obigen
Dringlichkeit (Feld "dringlichkeit"): "niedrig" | "normal" | "hoch" | "notfall"
- "notfall" nur bei akut eingeschlossener/eingeklemmter Person - jede Minute zählt.
- Ein stehender/gestörter Aufzug ohne eingeschlossene Person ist mindestens
"hoch", wenn er das einzige Fahrzeug im Gebäude ist oder Personen mit
Mobilitätseinschränkung betroffen sind - sonst "normal".
- Im Zweifel die höhere Stufe wählen: eine zu hoch eingestufte Nachricht
kostet einen Blick, eine zu niedrig eingestufte kann jemanden im Aufzug
gefangen lassen.
Gib ein JSON-Objekt mit genau diesen Feldern zurück:
{
"kategorie": "<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

@ -1,16 +0,0 @@
# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: ein Regex-Muster
# pro Zeile (Python re, IGNORECASE), leere Zeilen und "#"-Kommentare werden
# uebersprungen. Findet ein Muster im Transkript, eskaliert categorize.py
# Kategorie + Dringlichkeit hart auf "sicherheitsnetz_kategorie" aus
# kategorien.json ("eingeschlossen") - unabhaengig davon, was das Modell
# selbst einordnet. Nur nach oben, nie nach unten.
eingeschlossen
eingeklemmt
(sitzt|steckt|stecken)\s+fest
komm(e|en)\s+nicht\s+(mehr\s+)?raus
kommt?\s+nicht\s+(mehr\s+)?(raus|heraus)
t[üu]r\s+(geht|l[äa]sst\s+sich)\s+nicht\s+(auf|[öo]ffnen)
notruftaste
kabine\s+(h[äa]ngt|steht)\s+fest
seit\s+\w+\s+(stunden?|minuten?)\s+im\s+aufzug
person\s+im\s+aufzug

View file

@ -1,19 +0,0 @@
{
"profil_name": "Hausarztpraxis",
"kategorien": [
"termin", "rezept", "ueberweisung", "befund",
"verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges"
],
"kategorie_labels": {
"termin": "Termin",
"rezept": "Rezept",
"ueberweisung": "Überweisung",
"befund": "Befund",
"verwaltung": "Verwaltung",
"beschwerden": "Beschwerden",
"notfall": "Notfall",
"rueckruf": "Rückruf",
"sonstiges": "Sonstiges"
},
"sicherheitsnetz_kategorie": "notfall"
}

View file

@ -1,23 +0,0 @@
# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: ein Regex-Muster
# pro Zeile (Python re, IGNORECASE), leere Zeilen und "#"-Kommentare werden
# uebersprungen. Findet ein Muster im Transkript, eskaliert categorize.py
# Kategorie + Dringlichkeit hart auf "sicherheitsnetz_kategorie" aus
# kategorien.json ("notfall") - unabhaengig davon, was das Modell selbst
# einordnet. Nur nach oben, nie nach unten: ein Fehlalarm kostet einen Blick,
# ein uebersehener Notfall kann einen Patienten kosten.
#
# Grund fuer dieses Netz: getestet mit qwen3:8b, 3/3 identische Laeufe
# stuften "starke Brustschmerzen ... kriege kaum Luft" nur als "hoch" statt
# "notfall" ein - obwohl das Modell selbst "Brustschmerzen" und "Atemnot"
# als Stichworte erkannte. Bei Notfall-Erkennung darf man sich nicht allein
# auf ein LLM verlassen.
atemnot
(kriege|bekomme|krieg)\w*\s+(kaum|keine|schwer)\s+luft
kann\s+(kaum|nicht)\s+(mehr\s+)?atmen
brust\w*schmerz
schmerzen?\s+in\s+der\s+brust
bewusstlos
ohnm[aä]chtig
nicht\s+ansprechbar
blutet\s+(stark|heftig|sehr)
starke?\s+blutung

View file

@ -6,7 +6,3 @@ piper-tts>=1.2
certifi
# Nur nötig für STT_BACKEND=faster; der Standard ist whisper.cpp.
faster-whisper>=1.2
# Outbound-ESL-Bootstrap fuer die Pipecat-Testleitung (dialog/pipecat_bootstrap.py):
# nimmt FreeSWITCHs socket()-Uebergabe an, macht answer()+uuid_audio_stream+park().
greenswitch>=0.0.19

View file

@ -1,20 +1,16 @@
#!/bin/bash
# Erzeugt die Telefonansage aus profile/$PROFIL/ansage.txt (Branchen-Profil,
# siehe README).
# Erzeugt die Telefonansage aus telefon/ansage.txt.
# Ergebnis: telefon/ansage.wav in Telefonqualitaet (8 kHz mono, PCM16).
#
# Vier Quellen:
# Drei Sprachsynthesen:
# piper (Standard, natuerlicher, lokal) - Modelle unter ~/piper-voices
# say (macOS-Bordmittel, lokal, Rueckfall wenn Piper fehlt)
# mistral (Voxtral-API, Cloud - nur fuer diesen einmaligen Erzeugungslauf;
# die fertige ansage.wav wird danach lokal fuer jeden Anruf
# wiederverwendet, es geht dabei keine Patientendaten raus)
# datei (echte Aufnahme statt Synthese - profile/$PROFIL/ansage.<ext>,
# ansage.txt wird dabei nicht gelesen)
#
# Steuerung ueber Umgebungsvariablen bzw. .env:
# PROFIL=<Ordnername unter profile/, Default "praxis">
# ANSAGE_TTS=piper|say|mistral|datei
# ANSAGE_TTS=piper|say|mistral
# 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,
@ -36,10 +32,6 @@ 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}"
@ -58,28 +50,10 @@ 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' ' ' < "$ANSAGE_TXT" \
tr '\n' ' ' < telefon/ansage.txt \
| python3 -m piper -m "$MODELL" -s "$EMOTION" --length-scale "$TEMPO" \
-f "$TMP/sprache_roh.wav"
ffmpeg -v error -y -i "$TMP/sprache_roh.wav" -ar 8000 -ac 1 "$TMP/sprache.wav"
@ -87,7 +61,7 @@ case "$TTS" in
;;
say)
SAY_STIMME="${ANSAGE_STIMME:-Anna}"
say -v "$SAY_STIMME" -r 180 -f "$ANSAGE_TXT" -o "$TMP/sprache.aiff"
say -v "$SAY_STIMME" -r 180 -f telefon/ansage.txt -o "$TMP/sprache.aiff"
ffmpeg -v error -y -i "$TMP/sprache.aiff" -ar 8000 -ac 1 "$TMP/sprache.wav"
BESCHREIBUNG="say/$SAY_STIMME"
;;
@ -95,7 +69,7 @@ case "$TTS" in
MISTRAL_STIMME="${ANSAGE_STIMME:-en_paul_neutral}"
MISTRAL_MODELL="${MISTRAL_TTS_MODEL:-voxtral-mini-tts-latest}"
[ -n "${MISTRAL_KEY:-}" ] || { echo "MISTRAL_KEY fehlt (ANSAGE_TTS=mistral)" >&2; exit 1; }
TEXT="$(tr '\n' ' ' < "$ANSAGE_TXT")"
TEXT="$(tr '\n' ' ' < telefon/ansage.txt)"
PAYLOAD="$(TTS_MODEL="$MISTRAL_MODELL" TTS_VOICE="$MISTRAL_STIMME" python3 -c '
import json, os, sys
print(json.dumps({

View file

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

@ -1,21 +0,0 @@
<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,37 +44,14 @@ 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"
}
fuelle "$PROJEKT/telefon/freeswitch/plusnet.xml.tpl" "$FS_ETC/sip_profiles/external/plusnet.xml"
chmod 600 "$FS_ETC/sip_profiles/external/plusnet.xml" # enthaelt das SIP-Passwort
fuelle "$PROJEKT/telefon/freeswitch/praxis_ab.xml.tpl" "$FS_ETC/dialplan/public/00_praxis_ab.xml"
# Kontext fuer Taste 9 waehrend der Ansage - Top-Level unter dialplan/,
# nicht in public/ (siehe hauptnummer_menue.xml.tpl).
fuelle "$PROJEKT/telefon/freeswitch/hauptnummer_menue.xml.tpl" "$FS_ETC/dialplan/hauptnummer_menue.xml"
echo "Trunk -> $FS_ETC/sip_profiles/external/plusnet.xml (Rechte 600)"
echo "Dialplan -> $FS_ETC/dialplan/public/00_praxis_ab.xml"
echo "Ansage -> $PROJEKT/telefon/ansage.wav"
echo "Menue -> $FS_ETC/dialplan/hauptnummer_menue.xml (Taste 9 -> Astra)"
echo "Eingang -> $PROJEKT/telefon/eingang"
if [ -n "${DW3_DDI:-}" ]; then
fuelle "$PROJEKT/telefon/freeswitch/dw3_warten.xml.tpl" "$FS_ETC/dialplan/public/00_dw3_warten.xml"
echo "DW3 -> $FS_ETC/dialplan/public/00_dw3_warten.xml (DDI: $DW3_DDI, 20s Wartezeit)"
else
echo "DW3 -> uebersprungen (DW3_DDI leer in .env)"
fi
# DW9 (rufagent) -> Astra-Telefonagent. Dateiname "00_dw9_..." ist Absicht:
# muss alphabetisch vor 00_praxis_ab.xml einsortieren, siehe
# dw9_rufagent.xml.tpl fuer die Begruendung (2026-09-13 gefunden).
if [ -n "${DW9_DDI:-}" ]; then
fuelle "$PROJEKT/telefon/freeswitch/dw9_rufagent.xml.tpl" "$FS_ETC/dialplan/public/00_dw9_rufagent.xml"
echo "DW9 -> $FS_ETC/dialplan/public/00_dw9_rufagent.xml (DDI: $DW9_DDI, -> Astra)"
else
echo "DW9 -> uebersprungen (DW9_DDI leer in .env)"
fi

View file

@ -1,24 +0,0 @@
<include>
<!-- Eigener Kontext fuer die Tastenwahl waehrend der Ansage auf der
Hauptnummer. 00_praxis_ab.xml spielt telefon/ansage.wav per
play_and_get_digits und ruft danach
execute_extension hauptnummer_taste_${hauptnummer_taste} XML hauptnummer_menue
auf. Nur "hauptnummer_taste_9" hat hier eine Regel; ohne Taste findet
execute_extension nichts, kehrt zurueck und die Aufnahme startet.
Bewusst nicht im Kontext "public": dort matcht der Catch-all in
00_praxis_ab.xml jede Nummer und wuerde die Wahl verschlucken.
Liegt als Top-Level-Datei unter dialplan/ (wie agentzwei.xml), sonst
kennt FreeSWITCH den Kontextnamen nicht.
Taste 9 -> Astra, genau wie Durchwahl 9 (00_dw9_rufagent.xml). Kein
hangup danach: laeuft der Pipecat-Bootstrap nicht, kehrt socket sofort
zurueck und der Anrufer landet wenigstens auf dem Anrufbeantworter. -->
<context name="hauptnummer_menue">
<extension name="hauptnummer_taste_9">
<condition field="destination_number" expression="^hauptnummer_taste_9$">
<action application="socket" data="127.0.0.1:8095 async full"/>
</condition>
</extension>
</context>
</include>

View file

@ -9,14 +9,7 @@
<action application="answer"/>
<!-- kurze Pause, sonst schneidet die Gegenstelle den Anfang der Ansage ab -->
<action application="sleep" data="700"/>
<!-- Ansage mit Tastenwahl: 9 waehrend der Ansage -> Astra wie Durchwahl 9
(siehe hauptnummer_menue.xml.tpl). Ohne Taste nach 200 ms weiter zur
Aufnahme - die Ansage endet schon mit Signalton + 0,3 s Stille.
Argumente: min max versuche timeout_ms terminator datei
datei_bei_fehleingabe variable regex -->
<action application="play_and_get_digits"
data="1 1 1 200 # @@PROJEKT@@/telefon/ansage.wav silence_stream://10 hauptnummer_taste ^9$"/>
<action application="execute_extension" data="hauptnummer_taste_${hauptnummer_taste} XML hauptnummer_menue"/>
<action application="playback" data="@@PROJEKT@@/telefon/ansage.wav"/>
<!-- record: <datei> <max_sekunden> <stille_schwelle> <stille_sekunden> -->
<action application="record"
data="@@PROJEKT@@/telefon/eingang/${strftime(%Y%m%d-%H%M%S)}_${caller_id_number}.wav 120 200 4"/>

View file

@ -18,33 +18,6 @@ if [ -f .env ]; then
done < .env
fi
# Rohe Prozess-Logs (stdout/stderr von watcher/leitstand/monitor + die
# eigene Ausgabe hier, wenn per launchd-Watchdog alle 5 Min aufgerufen)
# wachsen sonst unbegrenzt - anders als verlauf.log (protokoll.kuerze(), nach
# jedem Anruf) und audit.log (bewusst permanent, siehe pipe/audit.py). Ab
# 2 MB auf die letzten 2000 Zeilen kuerzen, in-place (gleiches Inode) - sicher
# fuer >>-Redirects (O_APPEND springt bei jedem Schreiben ans Dateiende,
# unabhaengig vom vorherigen Offset).
LOG_GRENZE_BYTES=2000000
for log in telefon/autostart.log telefon/watcher.log telefon/leitstand.log telefon/monitor.log; do
if [ -f "$log" ] && [ "$(wc -c < "$log" 2>/dev/null || echo 0)" -gt "$LOG_GRENZE_BYTES" ]; then
tail -n 2000 "$log" > "$log.tmp" 2>/dev/null && cat "$log.tmp" > "$log" && rm -f "$log.tmp"
fi
done
# Stoerungswache als Erstes - sie muss gerade dann laufen, wenn der Rest nicht
# hochkommt (Vorabpruefung schlaegt fehl, FreeSWITCH stirbt, Trunk registriert
# nicht). Frueher startete sie erst nach Trunk UP und fehlte damit genau bei
# Ausfaellen: 2026-09-14 war die Leitung nach einem Neustart ~80 min tot, ohne
# Alarm. Sie meldet FreeSWITCH/Trunk selbst (pipe.dienste), beim normalen
# Hochfahren hoechstens einmal "gestoert" und kurz darauf "wieder ok".
if pgrep -f "[p]ipe.monitor" >/dev/null; then
echo "Stoerungswache laeuft bereits."
else
nohup python3 -u -m pipe.monitor >> telefon/monitor.log 2>&1 &
echo "Stoerungswache gestartet (Log: telefon/monitor.log)"
fi
fehler=0
melde() { printf ' %-38s %s\n' "$1" "$2"; }
@ -92,14 +65,7 @@ else
# -rp: Echtzeit-Priorität. Auf einem Rechner, der nebenher Whisper und ein
# Sprachmodell laufen lässt, kommt FreeSWITCH sonst nicht rechtzeitig dran und
# das Audio stückelt - RTP will alle 20 ms ein Paket losschicken.
# -nonat: keine NAT-PMP/UPnP-Erkennung am Router (192.168.2.1:5351). Unter
# launchd (Watchdog) blockiert macOS' "Lokales Netzwerk"-Datenschutz jedes
# Senden ins LAN mit EPIPE; so frueh im Start ignoriert FreeSWITCH SIGPIPE
# noch nicht und stirbt 0,5 s nach dem Backgrounding - ohne Log, ohne
# Crash-Report. Aus einer Terminal-Shell passiert das nicht, deshalb lief es
# manuell. Der Router antwortet ohnehin nicht (nat_map status: UNKNOWN); die
# externe IP kommt per STUN (vars.xml), Trunks und Sockets brauchen kein LAN.
freeswitch -nc -rp -nonat -nonatmap \
freeswitch -nc -rp -nonatmap \
-conf "$FS_ETC" \
-log /opt/homebrew/var/log/freeswitch \
-db /opt/homebrew/var/lib/freeswitch/db \
@ -108,30 +74,6 @@ else
sleep 15
fi
# Oeffentliche IP abgleichen. FreeSWITCH holt sie per STUN nur beim Laden der
# XML (vars.xml, stun-set) und meldet sie Plusnet als Contact. Nach einer
# Zwangstrennung des Routers mit neuer IP bleibt der Trunk trotzdem REGED/UP
# (Registrierung und Pings gehen raus), aber eingehende INVITEs laufen an die
# alte Adresse - die ganze Leitung ist stumm, ohne Fehler im Log. So am
# 2026-09-17 passiert: Contact 9.246.125.72, tatsaechlich 217.142.18.120.
# Bei Abweichung: reloadxml (STUN neu) + external-Profil neu starten, nur wenn
# gerade kein Gespraech laeuft - sonst im naechsten Watchdog-Lauf.
aktuell=$(fs_cli -P "$FS_PORT" -x "stun stun.freeswitch.org" 2>/dev/null | head -1 | cut -d: -f1)
gemeldet=$(fs_cli -P "$FS_PORT" -x "sofia status profile external" 2>/dev/null \
| awk '/^Ext-SIP-IP/{print $2}')
if [[ "$aktuell" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && [ -n "$gemeldet" ] \
&& [ "$aktuell" != "$gemeldet" ]; then
gespraeche=$(fs_cli -P "$FS_PORT" -x "show calls count" 2>/dev/null | awk '/total/{print $1}')
if [ "${gespraeche:-0}" = "0" ]; then
echo "$(date '+%F %T') Oeffentliche IP geaendert ($gemeldet -> $aktuell) - external-Profil wird neu gestartet."
fs_cli -P "$FS_PORT" -x "reloadxml" >/dev/null 2>&1
fs_cli -P "$FS_PORT" -x "sofia profile external restart" >/dev/null 2>&1
sleep 10
else
echo "$(date '+%F %T') Oeffentliche IP geaendert ($gemeldet -> $aktuell), aber $gespraeche Gespraech(e) aktiv - naechster Lauf."
fi
fi
# Auf die Registrierung beim Provider warten.
# Quelle ist fs_cli, nicht das Log: das Log wird zum Testen geleert und taugt
# dann nicht mehr als Zustandsquelle.
@ -157,27 +99,15 @@ if [ "${status:-}" = "UP" ]; then
else
# nice 10: Transkription und Kategorisierung dürfen warten, ein laufendes
# Telefonat nicht. Auf 16 GB RAM konkurrieren beide sonst um die Maschine.
nohup nice -n 10 python3 -u -m pipe.watch >> telefon/watcher.log 2>&1 &
nohup nice -n 10 python3 -u -m pipe.watch > telefon/watcher.log 2>&1 &
echo "Verarbeitung gestartet (Log: telefon/watcher.log)"
fi
if pgrep -f "[p]ipe.server" >/dev/null; then
echo "Leitstand laeuft bereits."
else
nohup python3 -u -m pipe.server >> telefon/leitstand.log 2>&1 &
nohup python3 -u -m pipe.server > telefon/leitstand.log 2>&1 &
sleep 2
tail -1 telefon/leitstand.log
fi
# Pipecat-Testleitung (Nebenstelle 7501) - nur wenn konfiguriert, siehe
# dialog/pipecat_bootstrap.py und dialplan/default/06_agentzwei_pipecat_test.xml.
if [ -n "${ASTRA_TELEFON_WS_URL:-}" ]; then
if pgrep -f "[d]ialog.pipecat_bootstrap" >/dev/null; then
echo "Pipecat-Bootstrap laeuft bereits."
else
# Aus der Projekt-venv: greenswitch/gevent fehlen im System-python3,
# dort bricht der Bootstrap sofort mit ModuleNotFoundError ab.
nohup .venv/bin/python -u -m dialog.pipecat_bootstrap >> telefon/pipecat_bootstrap.log 2>&1 &
echo "Pipecat-Bootstrap gestartet (Log: telefon/pipecat_bootstrap.log)"
fi
head -1 telefon/leitstand.log
fi
sleep 2
echo