diff --git a/.env.example b/.env.example
index 9e78dda..594c269 100644
--- a/.env.example
+++ b/.env.example
@@ -65,6 +65,17 @@ 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)
@@ -117,3 +128,9 @@ FS_ETC=/opt/homebrew/etc/freeswitch
# Log von FreeSWITCH; der Leitstand liest daraus die Anruf-Ereignisse.
# Apple Silicon: /opt/homebrew/... , Intel-Mac: /usr/local/...
#FREESWITCH_LOG=/opt/homebrew/var/log/freeswitch/freeswitch.log
+
+# Pipecat-Testleitung (dialog/pipecat_bootstrap.py, Nebenstelle 7501) -
+# WebSocket-Ziel fuer mod_audio_stream. token= muss ASTRA_TELEFON_SECRET
+# auf der Astra-Maschine entsprechen.
+ASTRA_TELEFON_WS_URL=wss://astra-host.example.ts.net/telefon/inbound?token=changeme
+PIPECAT_BOOTSTRAP_PORT=8095
diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644
index 0000000..354ab0c
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,289 @@
+# Handoff — Live-Telefonagent (laufend, zuletzt aktualisiert 2026-09-14)
+
+## Stand in einem Satz
+
+Voller Weg funktioniert Ende-zu-Ende: externer Anruf auf Durchwahl 9 (Plusnet → FreeSWITCH → `dialog/pipecat_bootstrap.py` → WebSocket → Astra) erreicht Astra, und Astra liefert jetzt auch eine echte, fertige Antwort statt nur "Einen Moment bitte" (Fix 2026-09-13, siehe unten) - kein offener Blocker mehr bekannt.
+
+## 2026-09-14 — Leitung nach Neustart ~80 min tot: Watchdog bekam nichts hoch
+
+**Symptom:** Nach einem Neustart des Mac mini (~12:39) blieb die Leitung
+(PROFIL=aufzug-notdienst) tot, bis ~13:58 von Hand gestartet wurde. Der
+launchd-Watchdog (`~/Library/LaunchAgents/net.dillenberg.fonagent.plist`,
+alle 5 min `starten.sh`) versuchte es 11-mal: jeweils "Backgrounding.",
+dann "fs_cli antwortet nicht", exit 1. `freeswitch.log` blieb unberührt,
+kein Crash-Report. Laut `autostart.log` hat der Watchdog FreeSWITCH **noch
+nie** erfolgreich gestartet - bis dahin lief es offenbar immer manuell.
+
+Drei Ursachen, drei Fixes:
+
+### 1. FreeSWITCH stirbt unter launchd an SIGPIPE (Commit `f785ab3`)
+
+macOS' Datenschutz "Lokales Netzwerk" gilt für launchd-Prozesse, nicht für
+Terminal-Shells: jedes Senden an eine LAN-Adresse (192.168.x) endet dort mit
+`EPIPE`, Internet und 127.0.0.1 gehen. FreeSWITCH fragt beim Start per NAT-PMP
+den Router (`192.168.2.1:5351`) - auch mit `-nonatmap`, das schaltet nur das
+Port-Mapping ab, nicht die Erkennung. So früh ignoriert FreeSWITCH SIGPIPE noch
+nicht -> Prozess tot ~0,5 s nach dem Backgrounding.
+
+Belegt mit einer zweiten FreeSWITCH-Instanz (eigene Ports, keine Gateways) über
+einen temporären LaunchAgent: `-nc -rp -nonatmap` stirbt reproduzierbar,
+`-nf` endet mit exit 141 (SIGPIPE), mit `-nonat` läuft sie. Unified Log im
+Todesmoment: `UserEventAgent: Got local network blocked notification`. Ein
+Python-UDP-Test unter launchd: Router -> Errno 32, 9.9.9.9 -> ok.
+
+**Fix:** `-nonat` im FreeSWITCH-Aufruf in `starten.sh`. Kein Verlust - der
+Router hat NAT-PMP nie beantwortet (`nat_map status`: UNKNOWN), die externe IP
+kommt per STUN.
+
+**Grenze, die bleibt:** Alles, was der Watchdog startet, erreicht **keine
+LAN-Geräte**. Heute unkritisch (Trunks übers Internet, Sockets auf 127.0.0.1,
+keine lokalen Telefone registriert). Kommen lokale SIP-Telefone o. Ä. dazu,
+bricht das unter launchd wieder - dann nicht lange suchen.
+
+**Noch nicht unter echten Bedingungen verifiziert:** `starten.sh` selbst hat
+FreeSWITCH mit `-nonat` noch nicht per Watchdog gestartet (solange es läuft,
+überspringt das Skript es). Test: FreeSWITCH stoppen, dann
+`launchctl kickstart gui/$(id -u)/net.dillenberg.fonagent` - ~1 min Ausfall.
+
+### 2. Pipecat-Bootstrap brach mit ModuleNotFoundError ab (Commit `907f511`)
+
+`starten.sh` startete `dialog.pipecat_bootstrap` mit System-`python3`, dem
+`gevent`/`greenswitch` fehlen. Jetzt `.venv/bin/python`. Die übrigen
+`pipe.*`-Dienste laufen weiterhin mit System-`python3` (dort vollständig).
+
+### 3. Störungswache lief genau beim Ausfall nicht (Commit `6db89af`)
+
+`pipe.monitor` wurde erst nach Trunk UP gestartet - kam FreeSWITCH nicht hoch,
+gab es auch keine Wache und keinen Alarm. Jetzt startet sie als Erstes in
+`starten.sh`, noch vor der Vorabprüfung. `pipe.dienste` meldet "FreeSWITCH
+läuft nicht" bzw. "Trunk DOWN" selbst.
+
+**Offen:** Der Alarm ist nur eine lokale macOS-Benachrichtigung (`pipe/alarm.py`,
+osascript) auf dem Mac mini - wenn dort niemand vor dem Bildschirm sitzt, sieht
+ihn keiner. Für eine Notdienst-Leitung bräuchte es einen Push-Kanal nach außen.
+
+## 2026-09-13 (Tag) — Zwei Bugs gefunden und behoben
+
+### 1. Durchwahl 9 landete bei der Praxis-Zentrale statt bei Astra
+
+**Symptom:** externer Anruf auf +49 2103 78916179 (Durchwahl 9, "rufagent" im
+Fonial-Portal, Status Online) klingelte durch, aber es meldete sich die
+Praxis-Ansage (`00_praxis_ab.xml`) statt Astra.
+
+**Untersuchung:** `${sip_h_X-ORIGINAL-DDI-URI}` kam korrekt mit
+`sip:+49210378916179@ipfonie.de` an — die DDI-Erkennung selbst war NICHT das
+Problem (anders als zunächst vermutet). Es gab schlicht **keine Dialplan-Regel,
+die auf diese DDI reagiert** — nur `00_dw3_warten.xml` (reagiert exklusiv auf
+Durchwahl 3) existierte, alles andere fällt durch zu `00_praxis_ab.xml`
+(`^.*$`, kein `continue="true"`, matcht daher wortwörtlich jede Nummer).
+
+**Erster Fix-Versuch schlug fehl, zweiter Grund gefunden:** eine neue Regel
+in einer Datei namens `00_rufagent_astra.xml` wurde nach `reloadxml` immer
+noch nicht erreicht — der Dialplan-Trace (`fs_cli -x "originate ... &echo()"`-
+Anrufe mit `call_debug=true` oder `[INFO]`-Log direkt mitlesen) zeigte, dass
+die Regel im geparsten Dialplan gar nicht auftauchte. Grund: `public.xml`
+bindet den ganzen Ordner per ``
+**alphabetisch** ein, und `00_praxis_ab.xml`s Catch-all kommt alphabetisch
+VOR `00_rufagent_astra.xml` (`p` < `r`) und beendet die Dialplan-Auswertung,
+bevor die eigene Regel überhaupt geladen wird.
+
+**Fix:** Datei umbenannt zu `00_dw9_rufagent.xml` (`d` < `p`, sortiert direkt
+hinter `00_dw3_warten.xml` und vor `00_praxis_ab.xml`). Als Vorlage ins Repo
+übernommen: `telefon/freeswitch/dw9_rufagent.xml.tpl` + `DW9_DDI` in `.env`
++ neuer Block in `telefon/freeswitch/einrichten.sh` (analog zum
+DW3-Block) — die Live-Config kommt jetzt wieder komplett aus dem Repo,
+reproduzierbar per `einrichten.sh`.
+
+**Lehre für weitere Durchwahlen:** Jede neue `dialplan/public/*.xml`-Regel,
+die vor `00_praxis_ab.xml` greifen soll, MUSS einen Dateinamen bekommen, der
+alphabetisch vor `p` einsortiert (Konvention: `00_dwN_.xml`, `N` als
+Ziffer direkt nach `00_dw`). Sonst wird sie stillschweigend nie erreicht -
+kein Fehler, kein Log-Eintrag, sie taucht im Dialplan-Trace einfach nicht auf.
+
+### 2. Telephone.app kann eine DDI-Registrierung stehlen
+
+Telephone.app (macOS-Softphone) lief auf minim4-1 mit den SIP-Zugangsdaten
+einer der Fonial-Durchwahlen für manuelles SIP-Debugging (siehe
+`/tmp/telephone_call.pcap`-Mitschnitt dort). Läuft es gleichzeitig mit
+FreeSWITCHs eigener Registrierung derselben Durchwahl, ist nicht
+deterministisch, wer den Anruf bekommt - siehe auch die Warnung in
+`telefon/starten.sh` ("Telephone.app LAEUFT - beenden, sonst
+Registrierungskonflikt"). Vor jedem externen Testanruf prüfen:
+`pgrep -xl Telephone` auf der Maschine, auf der es installiert ist (hier:
+minim4-1, nicht minim4-2!), und bei Bedarf beenden.
+
+## GELÖST 2026-09-13: keine fertige Antwort am Telefon
+
+Realer externer Testanruf (2026-09-13, ~12:14 Uhr) mit echtem Sprecher:
+
+- Audio kommt sauber an (Peak ~3000, kein Stille-Artefakt wie bei
+ Loopback/Selbstanruf-Tests).
+- STT transkribiert korrekt: "kannst du mich hören?", danach "Hallo."
+- Astra spielt beide Male nur den Zwischenbescheid **"Einen Moment bitte."**
+ - Danach läuft die eigentliche LLM-Antwort (`granite4.2:8b` über Ollama,
+ `NativeOllamaService`) im Astra-Log als `'phase': 'running'` weiter,
+ wird aber nie fertig:
+ - 1. Versuch: nach 4814 ms durch neue erkannte Nutzer-Sprache
+ (`VADUserTurnStartStrategy`) abgebrochen (`'phase': 'cancelled'`) -
+ der Anrufer hat vermutlich aus Ungeduld nochmal "Hallo" gesagt.
+ - 2. Versuch: nach 6002 ms abgebrochen, weil der Anrufer aufgelegt hat
+ (`Anruf beendet` direkt danach im Log, keine erneute Nutzer-Sprache).
+ - In beiden Fällen: **keine einzige echte Antwort hat es bis zur TTS
+ geschafft**, nur der Zwischenbescheid.
+
+**Hypothese:** Kein Barge-in/Echo-Problem (anders als in der vorherigen
+Handoff-Version vermutet) - hier ist die reale Sprache eindeutig neu, kein
+Echo-Artefakt. Verdacht: `granite4.2:8b` per Ollama ist für die
+Telefon-Pipeline schlicht zu langsam (>5s ohne fertige Antwort), der
+Anrufer gibt vorher auf. Zu prüfen morgen:
+- Ollama-Antwortzeit isoliert messen (gleicher Prompt, gleiches Modell,
+ ohne Telefon-Pipeline drumherum) - liegt es am Modell/Backend oder an
+ Rechenlast durch parallel laufende STT/TTS-Modelle auf demselben Mac?
+- Ob `ASTRA_OLLAMA_URL`/`ASTRA_OLLAMA_BACKENDS` für die Telefonie-Pipeline
+ auf ein schnelleres/entlastetes Backend zeigen sollte (siehe README
+ "Ollama-Backend wählen").
+- Ob ein kürzerer/schnellerer Zwischenbescheid-Rhythmus (z. B. alle 2-3s
+ ein weiteres "Moment noch") das gefühlte Hängen entschärfen würde, während
+ am eigentlichen Latenzproblem gearbeitet wird.
+
+**Root Cause bestätigt:** `granite4.2:8b` war zwischen dem Server-Start
+(01:50 Uhr) und dem Testanruf (12:14 Uhr) aus Ollama verdrängt worden, trotz
+`keep_alive: -1` in `astra/core.py::build_request` - `ollama ps` zeigte
+davor `"models": []`. Direkt gemessen: kalt `load_duration` ~12,2s, warm
+Gesamtdauer <0,9s. Der Anrufer wurde ungeduldig/legte auf, lange bevor die
+eigentliche Generierung (nach dem Laden nur ~1s) fertig war.
+
+**Fix:** `astra/server.py::telefon_inbound` feuert jetzt beim
+Verbindungsaufbau parallel zur Begrüßung einen Wegwerf-Generate-Aufruf an
+Ollama (`_warm_ollama`, fire-and-forget, Fehler werden ignoriert) - der
+Kaltstart-Tax landet dadurch während die Begrüßung läuft, nicht während der
+ersten echten Antwort. Verifiziert mit echtem externen Testanruf: LLM-Zeit
+1355 ms, vollständige Antwort kam durch, keine Abbrüche mehr. Commit
+`a66629a` in `martin-voice-interface`.
+
+**Falls das Problem wiederkehrt** (z. B. weil ein anderer Ollama-Verbraucher
+zwischen Anrufen ein anderes Modell lädt und `granite4.2:8b` trotzdem wieder
+verdrängt wird): `curl http://127.0.0.1:11434/api/ps` vor einem Testanruf
+prüfen, ob das Modell geladen ist.
+
+## Weiterhin offen (aus der vorherigen Handoff-Fassung, unverändert)
+
+### Sitzungs-Hänger-Bug (Astra `busy: true` bleibt hängen)
+
+Noch nicht erneut beobachtet/verifiziert heute, aber nicht aktiv behoben -
+weiter im Auge behalten: `sessions`-Gate in `astra/server.py` könnte bei
+einem nicht sauber geschlossenen WebSocket (z. B. hartes `hupall`) hängen
+bleiben, siehe `run_telephony_call`/`_run_pipeline`s Cleanup-Pfad.
+
+## Werkzeuge/Referenzen
+
+- **Test-Nummer** (externe echte Rufnummer, Astra-Testleitung):
+ **+49 2103 78916179** (Durchwahl 9 / "rufagent" im Fonial-Portal). Vor dem
+ Test: Telephone.app auf minim4-1 beenden, falls es läuft
+ (`pgrep -xl Telephone`).
+- **Loopback (ext. 7501, kein echtes Telefon)**: nur zum Prüfen der
+ Signalisierung/Verkabelung geeignet, liefert IMMER Stille (Peak ~1) - kein
+ Ersatz für einen echten Audiotest.
+ `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'originate loopback/7501 &park()'"`
+- **Selbstanruf** (`dialog.anrufen 2`): unzuverlässig, liefert ebenfalls nur
+ Stille (bestätigt 2026-09-13) - nicht mehr nötig jetzt, wo DW9 extern
+ erreichbar ist.
+- **Nach jedem Testanruf auflegen**:
+ `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'hupall NORMAL_CLEARING'"`
+- **FreeSWITCH-Dialplan neu laden** (nach Änderungen in `dialplan/public/`,
+ kein Neustart nötig):
+ `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'reloadxml'"`
+- **Astras Live-Log**: `/private/tmp/astra_server.log` auf minim4-1 (NICHT
+ `.runtime/server.log` - das ist ein alter, verwaister Lauf). Bei Zweifel,
+ wohin ein laufender Prozess loggt: `lsof -p -a -d 0,1,2`.
+- **Bootstrap-Log**: `fonagent-one/telefon/pipecat_bootstrap.log` auf
+ minim4-2.
+- **ESL-Port** auf minim4-2 ist **8022**, nicht der Standard 8021.
+- **Astra neu starten** (siehe vorherige Handoff-Fassung im Git-Log für den
+ vollständigen Befehl mit `ASTRA_TELEFON_SECRET`/`ASTRA_LOG_LEVEL`).
+
+## Zugangsdaten/Config
+
+- `.env` auf minim4-2 im Projekt (gitignored) — SIP-Zugänge für `plusnet`,
+ `agentzwei`, `agentzwei_sbc`, `ASTRA_TELEFON_WS_URL`, jetzt auch
+ `DW9_DDI=49210378916179`.
+- `ASTRA_TELEFON_SECRET` — nur als Laufzeit-Env-Var gesetzt, nicht in einer
+ Datei auf minim4-1 abgelegt. Muss auf beiden Seiten übereinstimmen.
+
+## GELÖST 2026-09-13 (Folgefehler): Begrüßung komplett stumm nach dem Cap-Fix
+
+Der Cap-Fix oben (Puffer auf 12s angehoben) legte einen zweiten, tieferen Bug
+frei: `TTSStoppedFrame` erreicht `serialize()` in diesem Transport **nie** -
+per Frame-Type-Tracing bestätigt, `FastAPIWebsocketOutputTransport` reicht an
+den Serializer nur `OutputAudioRawFrame` und (bei Barge-in) `InterruptionFrame`
+durch, `TTSStoppedFrame` wird intern fürs "Bot hat aufgehört zu sprechen"
+verbraucht und nie weitergereicht. Der `if isinstance(frame, TTSStoppedFrame)`-
+Zweig war also von Anfang an toter Code - geflusht wurde bisher ausschließlich
+über die (zu klein bemessene) Cap. Mit der korrigierten, viel höheren Cap
+erreichte eine normal lange Antwort (3-4s) diese nie mehr - und ohne den
+(toten) TTSStoppedFrame-Pfad flusste dann gar nichts mehr: Astra loggte
+Begrüßung + Transkript ganz normal, aber bei FreeSWITCH kam kein einziges
+Playback-Event an.
+
+**Fix:** Flush jetzt über ein Inaktivitäts-Timeout (300ms ohne neuen
+Audio-Frame) statt über `TTSStoppedFrame`. Da der Idle-Flush aus einem
+Hintergrund-Task feuert (nicht aus `serialize()`s Rückgabewert), braucht er
+einen eigenen Sendeweg - der Serializer nimmt jetzt zusätzlich zu `notify`
+ein `send`-Callable (`websocket.send_text`). Commit `0a0c8d5` in
+`martin-voice-interface`.
+
+Live verifiziert (Loopback-Test): 2,88s gesendet, FreeSWITCH spielt 3,02s
+vollständig ab ("done playing file") - keine Stille, kein Abschneiden mehr.
+
+## GELÖST 2026-09-13 (weitere Folgefehler): zu lange Antworten + hackige Wiedergabe
+
+Nach dem Idle-Flush-Fix zwei weitere Probleme live gemeldet:
+
+1. **Antworten zu lang** - der Prompt allein ("1-2 kurze Sätze") wurde vom
+ Modell ignoriert, Antworten liefen auf 2-3 Sätze und lang genug, um die
+ 12s-Puffergrenze zu reißen. Fix: harte `num_predict`-Grenze
+ (`Settings.telefon_max_tokens`, Standard 40, `ASTRA_TELEFON_MAX_TOKENS`
+ env-einstellbar) statt nur einer Bitte im Prompt. Commit `4e6f266`.
+2. **Wiedergabe "hackig"** - der 300ms-Idle-Flush (siehe vorheriger Eintrag)
+ feuerte bei Mehrsatz-Antworten fast immer VOR dem nächsten Satz, weil die
+ Pause zwischen zwei TTS-Satz-Chunks (das LLM muss den nächsten Satz erst
+ zu Ende streamen) regelmäßig über 300ms liegt - jeder Satz kam so als
+ eigene, einzeln abgespielte Nachricht statt als eine zusammenhängende
+ Antwort. Fix: `flush_now()` auf dem Serializer, ausgelöst über
+ `run_telephony_call`s `notify()`-Hook genau dann, wenn die Pipeline
+ meldet, dass der Bot wirklich fertig gesprochen hat
+ (`BotStoppedSpeakingFrame` via den `'state': 'listening'`-Callback) - das
+ ist das einzige verlässliche "Antwort fertig"-Signal, da `TTSStoppedFrame`
+ selbst `serialize()` nie erreicht (siehe voriger Eintrag). Der
+ Idle-Timeout ist jetzt nur noch Fallback (4s statt 300ms). Commit
+ `42acca4`.
+
+Live verifiziert: Begrüßung flusht jetzt ~2ms nach echtem Sprechende (statt
+vorher 300ms Verzögerung), vollständig, keine Aufteilung.
+
+**Offen für nächsten Test:** ob Mehrsatz-Antworten jetzt tatsächlich flüssig
+klingen (nur mit Loopback getestet, das produziert keine echte Sprache für
+einen mehrteiligen Dialog) - braucht einen echten Anruf mit echtem Gespräch.
+
+## GELÖST 2026-09-13 (weiterer Folgefehler): Anruf trennt nach ~50s ohne Antwort
+
+Erster echter Mehrsatz-Dialog lief mehrere Runden gut (kurze Antworten,
+zusammenhängende Wiedergabe) - dann zweimal hintereinander: VAD hat ausgelöst
+(`min_volume=0.3` reagiert absichtlich auch auf leise Signale, siehe
+vorheriger Eintrag), aber Nemotron hat für die ganze Runde nichts
+transkribiert (`Nemotron result: final, 0 characters`). Ein leeres Transkript
+erreicht `LLMUserAggregator` nie als echte Nutzer-Nachricht - keine
+LLM-Anfrage, keine Antwort, keine Rückmeldung. Für den Anrufer wirkt das wie
+eine tote Leitung; nach zwei solchen Runden hat er aufgelegt.
+
+**Fix:** `NemotronSTTService` bekommt jetzt optional `no_speech_phrases`
+(`astra.services.NO_SPEECH_PHRASES`, z. B. "Wie bitte?", "Kannst du das
+wiederholen?") und spricht bei leerem finalem Transkript eine davon über
+`TTSSpeakFrame`. Nur für die Telefonie verdrahtet (Browser hat schon einen
+sichtbaren "listening"-Zustand). Commit `c8946d0`.
+
+**Noch offen:** ob `min_volume=0.3` grundsätzlich zu locker ist (löst zu oft
+auf Nicht-Sprache aus) - dieser Fix behandelt nur das Symptom (Anrufer bekommt
+immer eine Reaktion), nicht die Ursache. Bei wiederholtem Auftreten:
+`ASTRA_TELEFON_VAD_MIN_VOLUME` schrittweise erhöhen und beobachten, ob echte
+leise Sprache dann noch erkannt wird.
diff --git a/README.md b/README.md
index 8f1f0ff..111c480 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,6 @@ ab (lokal und optional in Nextcloud). Für welchen Einsatzbereich (Hausarztpraxi
> Musterhausen" ist ein Platzhalter. Echte Zugangsdaten und Anrufdaten liegen
> außerhalb des Repositorys (`.env`, `ablage/`, `telefon/`).
-Wer das Projekt auf einem Linux einrichten möchte, eine Kleine Hilfe: [docs/MITGABE.md](docs/MITGABE.md)
-sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen.
-
## Stufen
1. **Die Pipe** (fertig): Audio → Transkript → Kategorie → Ablage.
@@ -239,6 +236,35 @@ 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
ausführen. Für den Praxisbetrieb muss die Ansage den Notruf 112 nennen und auf
diff --git a/dialog/__init__.py b/dialog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dialog/anrufen.py b/dialog/anrufen.py
new file mode 100644
index 0000000..0c0302b
--- /dev/null
+++ b/dialog/anrufen.py
@@ -0,0 +1,68 @@
+"""Outbound-Call-Trigger fuer die Pipecat-Testleitung.
+
+Ruft eine Nummer ueber den Plusnet-Trunk an und uebergibt sie nach Abheben an
+dieselbe Bruecke wie die interne Testnebenstelle 7501
+(dialog/pipecat_bootstrap.py, Port 8095) - answer()+uuid_audio_stream+park()
+laeuft dort identisch, unabhaengig davon ob der Anruf rein- oder rausgeht. Der
+Dialplan-Umweg (Nebenstelle 7501) entfaellt hier: `&socket(...)` haengt den
+ausgehenden Kanal direkt nach Abheben an den Bootstrap, ohne Dialplan-Lookup.
+
+Nutzung: python3 -m dialog.anrufen
+"""
+
+import os
+import sys
+
+import greenswitch
+
+FS_HOST = "127.0.0.1"
+FS_PORT = int(os.environ.get("FS_PORT", "8022"))
+FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon")
+BOOTSTRAP_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095"))
+# DW9 ("rufagent") - the only account confirmed capable of outbound so far.
+# Needs "proxy" (R-URI domain, must be sip.plusnet.de - Plusnet 403s otherwise)
+# split from "outbound-proxy" (actual packet routing, sbc.sip.plusnet.de) in
+# its gateway XML - see sip_profiles/external/agentzwei_sbc.xml.
+GATEWAY = os.environ.get("ANRUF_GATEWAY", "agentzwei_sbc")
+CALLER_ID = os.environ.get("ANRUF_CALLER_ID", "+49210378916179")
+
+
+def normalisiere(nummer: str) -> str:
+ """Wie pipe/kontakte.py: nur Ziffern, Deutschland-Annahme 0 -> 49,
+ kein '+' - so erwartet es der Trunk auch fuer DW3_DDI in der .env."""
+ ziffern = "".join(zeichen for zeichen in nummer if zeichen.isdigit() or zeichen == "+")
+ if ziffern.startswith("+"):
+ return ziffern[1:]
+ if ziffern.startswith("00"):
+ return ziffern[2:]
+ if ziffern.startswith("0"):
+ return "49" + ziffern[1:]
+ return ziffern
+
+
+def main():
+ if len(sys.argv) != 2:
+ sys.exit("Nutzung: python3 -m dialog.anrufen ")
+ ziel = normalisiere(sys.argv[1])
+ # The &socket(...) part MUST stay in single quotes: originate splits its
+ # arguments on spaces, so an unquoted "&socket(127.0.0.1:8095 async full)"
+ # reaches the channel as plain "socket(127.0.0.1:8095)" - "async full"
+ # silently dropped (seen in freeswitch.log's own EXECUTE line, 2026-09-13).
+ # Without "full", mod_event_socket refuses every command past sendmsg on
+ # an outbound listener, so pipecat_bootstrap's CUSTOM-event subscription
+ # ("event plain CUSTOM mod_audio_stream::play") is answered with
+ # "-ERR command not found" and no audio is ever played back.
+ befehl = (
+ f"originate {{origination_caller_id_number={CALLER_ID}}}"
+ f"sofia/gateway/{GATEWAY}/{ziel} "
+ f"'&socket({FS_HOST}:{BOOTSTRAP_PORT} async full)'"
+ )
+ print(f"Rufe {ziel} an ueber: {befehl}")
+ fs = greenswitch.InboundESL(host=FS_HOST, port=FS_PORT, password=FS_ESL_PASSWORD)
+ fs.connect()
+ antwort = fs.send(f"api {befehl}")
+ print(antwort.data)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dialog/pipecat_bootstrap.py b/dialog/pipecat_bootstrap.py
new file mode 100644
index 0000000..327f599
--- /dev/null
+++ b/dialog/pipecat_bootstrap.py
@@ -0,0 +1,187 @@
+"""Outbound-ESL bootstrap for the Pipecat phone-agent test line.
+
+FreeSWITCH's dialplan extension "agentzwei_pipecat_test" (destination_number
+7501, dialplan/default/06_agentzwei_pipecat_test.xml, internal test-only, not
+reachable from the PSTN) - and now also dialog/anrufen.py's outbound calls -
+hand the channel here via `socket(127.0.0.1:PORT async full)`. This process's
+job: answer(), tell mod_audio_stream to start streaming the call's audio to
+Astra's /telefon/inbound, then stay on the line for as long as the call lasts
+to actually play back what Astra sends - the conversation logic itself lives
+entirely in Astra's Pipecat pipeline, over the WebSocket that mod_audio_stream
+opens outbound to Astra.
+
+mod_audio_stream has no dialplan application, only the ESL API command
+`uuid_audio_stream` (confirmed against the module's own source and by
+testing). Confirmed by testing (2026-09-12): issuing `uuid_audio_stream` from
+this same process - even over a second, separate ESL connection - reliably
+fails the WebSocket connect for real external/gateway calls (instant
+"connection error", no such issue for the loopback test line). Matches a
+known mod_audio_stream issue (amigniter/mod_audio_stream#92): "It's an issue
+with using uuid_audio_stream on the same call session uuid as the Python
+script that's listening for the WebSocket, separating these processes
+resolves the problem." So the command is shelled out to `fs_cli` as a
+genuinely separate OS process, not issued from within this one at all.
+
+Second gap found by testing (2026-09-13): mod_audio_stream does NOT play
+audio back on its own. Astra's `streamAudio` message only makes it write a
+temp WAV file and fire a `mod_audio_stream::play` CUSTOM event containing that
+path (audio_streamer_glue.cpp:290-303) - something has to catch that event and
+actually play the file. Nothing in the module or the dialplan does this, so
+this process stays on the call (instead of returning right after park()) and
+plays each file back as the events arrive, until the call hangs up - with the
+`playback` application over this same outbound socket. Unlike uuid_audio_stream
+that is not an "api" command but an execute-style application call, exactly
+like the answer()/park() that already work here, so it does not need the
+separate-process detour.
+
+Getting those CUSTOM events delivered at all took two subscriptions plus one
+workaround; see run() and event_body_json() for the details.
+"""
+
+import json
+import logging
+import os
+import subprocess
+import sys
+
+import gevent
+import greenswitch
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("pipecat_bootstrap")
+
+BIND_ADDRESS = os.environ.get("PIPECAT_BOOTSTRAP_BIND", "127.0.0.1")
+BIND_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095"))
+ASTRA_WS_URL = os.environ.get("ASTRA_TELEFON_WS_URL", "")
+STREAM_MIX_TYPE = "mono"
+STREAM_SAMPLE_RATE = "16k" # matches NemotronSTTService's hard 16 kHz requirement on the Astra side
+# Upper bound so a greenlet can never outlive a call FreeSWITCH itself forgot
+# to tell us ended - failure mode is "one stray idle greenlet", not a runaway.
+MAX_CALL_SECONDS = 30 * 60
+
+FS_HOST = "127.0.0.1"
+FS_PORT = os.environ.get("FS_PORT", "8022")
+FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon")
+FS_CLI = os.environ.get("FS_CLI_BIN", "/opt/homebrew/bin/fs_cli")
+
+
+def fs_cli(command: str) -> str:
+ """Run one `api` command via `fs_cli` as its own OS process - see module
+ docstring for why commands for this call must not go over this process's
+ own outbound-socket connection."""
+ result = subprocess.run(
+ [FS_CLI, "-H", FS_HOST, "-P", FS_PORT, "-p", FS_ESL_PASSWORD, "-x", command],
+ capture_output=True, text=True, timeout=10,
+ )
+ return (result.stdout or result.stderr).strip()
+
+
+def event_body_json(event) -> dict | None:
+ """Read a FreeSWITCH event's body (the part after the blank line) as JSON.
+
+ greenswitch's ESLEvent.parse_data() only knows "key: value" lines, so an
+ event body - mod_audio_stream sends bare JSON, which has no ": " in it -
+ is never split off as its own field. It ends up appended to the value of
+ whatever header came last, which in plain format is always the body's own
+ "Content-Length". So `headers["Content-Length"]` reads e.g.
+ '84\\n\\n{"audioDataType":"wav","file":"/tmp/....wav"}' - the payload is
+ there, just glued to a number. Cut at the first "{" and parse that.
+ """
+ raw = event.headers.get("Content-Length") or ""
+ start = raw.find("{")
+ if start == -1:
+ return None
+ try:
+ payload = json.loads(raw[start:])
+ except (ValueError, TypeError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+class PipecatBridge:
+ """One instance per call, per greenswitch's OutboundESLServer contract."""
+
+ def __init__(self, session):
+ self.session = session
+ self.call_id = getattr(session, "uuid", "?")
+ self.hung_up = gevent.event.Event()
+
+ def on_play_event(self, event):
+ payload = event_body_json(event)
+ if payload is None:
+ logger.warning(
+ "Anruf %s: mod_audio_stream::play-Event ohne lesbaren JSON-Rumpf: %r",
+ self.call_id, event.headers,
+ )
+ return
+ path = payload.get("file")
+ if not path:
+ logger.warning("Anruf %s: play-Event ohne Dateipfad: %r", self.call_id, payload)
+ return
+ # Same mechanism Logpy-AgentOne already uses successfully in
+ # production (dialplan `playback` app) - over this session's own
+ # connection, unlike uuid_audio_stream this is an "execute"-style
+ # application call (like answer()/park(), already proven to work
+ # here), not an "api" command.
+ self.session.playback(path, block=False)
+ logger.info("Anruf %s: spiele %s ab", self.call_id, path)
+
+ def on_hangup(self, event):
+ self.hung_up.set()
+
+ def run(self):
+ try:
+ # Two separate subscriptions, both required, in this order:
+ #
+ # `myevents` turns on this session's own event feed, but it does
+ # NOT mean "all events" - mod_event_socket hardcodes a whitelist
+ # of ~23 event ids there (mod_event_socket.c, parse_command(),
+ # "myevents" branch), all of them CHANNEL_*/DTMF/TALK. CUSTOM is
+ # not in it, so no module's CUSTOM event can ever arrive on a
+ # myevents-only socket, no matter its subclass.
+ #
+ # `event plain CUSTOM ` adds exactly that: it sets
+ # event_list[CUSTOM] plus a subclass entry in the listener's
+ # event_hash. It is purely additive - it does not reset the
+ # whitelist myevents just installed (only `noevents`/`nixevent`
+ # clear entries), so the CHANNEL_* events keep coming. The
+ # LFLAG_MYEVENTS flag stays set too, so the CUSTOM events we now
+ # get are still filtered down to this channel's own Unique-ID.
+ #
+ # Needs the socket to be "full" (dialplan/originate say
+ # `socket(host:port async full)`): mod_event_socket drops every
+ # command past this point for a non-full outbound listener.
+ self.session.myevents()
+ self.session.send("event plain CUSTOM mod_audio_stream::play")
+ self.session.linger()
+ self.session.register_handle("mod_audio_stream::play", self.on_play_event)
+ self.session.register_handle("CHANNEL_HANGUP", self.on_hangup)
+ self.session.answer()
+ antwort = fs_cli(
+ f"uuid_audio_stream {self.call_id} start {ASTRA_WS_URL} "
+ f"{STREAM_MIX_TYPE} {STREAM_SAMPLE_RATE} {self.call_id}"
+ )
+ logger.info("Anruf %s: uuid_audio_stream gestartet, Antwort: %r", self.call_id, antwort)
+ self.session.park()
+ # Stay on the line - on_play_event needs to keep firing for the
+ # whole call, not just the first bot utterance.
+ self.hung_up.wait(timeout=MAX_CALL_SECONDS)
+ except Exception:
+ logger.exception("Anruf %s: Bootstrap fehlgeschlagen", self.call_id)
+ finally:
+ self.session.stop()
+
+
+def main():
+ if not ASTRA_WS_URL:
+ sys.exit("ASTRA_TELEFON_WS_URL fehlt in der Umgebung (.env) - Abbruch.")
+ logger.info("Pipecat-Bootstrap hoert auf %s:%s, Ziel: %s", BIND_ADDRESS, BIND_PORT, ASTRA_WS_URL)
+ server = greenswitch.OutboundESLServer(
+ bind_address=BIND_ADDRESS, bind_port=BIND_PORT,
+ application=PipecatBridge, max_connections=5,
+ )
+ server.listen()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/MITGABE.md b/docs/MITGABE.md
deleted file mode 100644
index cc7753f..0000000
--- a/docs/MITGABE.md
+++ /dev/null
@@ -1,129 +0,0 @@
-# Mitgabe für die Einrichtung auf einem zweiten Rechner
-
-Diese Datei ergänzt README (Einrichtung) und HANDOFF (Stand). Sie enthält das,
-was auf der anderen Seite nicht im Repository ankommt: die Erfahrungswerte aus
-dem ersten Volltest, die Stellen, an denen die Vorlagen unvollständig sind, und
-die Reihenfolge, in der man prüft, ob es wirklich läuft.
-
-## 1. Was im Repo *nicht* liegt
-
-| Fehlt | Woher |
-|---|---|
-| `.env` | aus `.env.example` kopieren, Werte eintragen (Abschnitt 3) |
-| Whisper-Modell (~1,6 GB) | `~/whisper-models/ggml-large-v3-turbo.bin`, Download siehe README |
-| Ollama-Modell (~5,2 GB) | `ollama pull qwen3:8b` |
-| Piper-Stimme | `~/piper-voices/…`, nur für die Ansage (Stufe 2) nötig |
-| `ablage/`, `telefon/eingang/`, `dashboard.html` | Anruf- und Patientendaten, bleiben absichtlich lokal |
-| FreeSWITCH-Konfiguration mit echtem SIP-Passwort | `telefon/freeswitch/einrichten.sh` schreibt sie aus der `.env` |
-
-Alle Namen, Rufnummern und Beispieldaten im Repo sind erfunden. „Praxis
-Musterhausen" ist Platzhalter — auch im Screenshot.
-
-## 2. Der kurze Weg: Stufe 1 ohne Telefonie
-
-Telefonie (FreeSWITCH, SIP-Trunk) ist für die Erprobung nicht nötig und macht
-die meiste Arbeit. Für einen ersten Durchlauf reicht:
-
-```bash
-brew install ffmpeg whisper-cpp
-brew install ollama && ollama serve &
-ollama pull qwen3:8b
-mkdir -p ~/whisper-models && cd ~/whisper-models
-curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin
-cd ~/praxis-telefon-agent && cp .env.example .env
-python3 -m pipe.process_call test/rezept.wav --nummer 021031234567
-```
-
-Läuft das durch (~25 s), liegt das Ergebnis unter
-`ablage/JJJJ-MM-TT/HH-MM-SS_021031234567/`. Nextcloud, Deck, Leitstand und
-Telefonie sind alle abschaltbar und kommen erst danach dran.
-
-## 3. Was die `.env.example` verschweigt
-
-Die Vorlage deckt nicht alle Schalter ab, die `pipe/config.py` liest. Die
-Voreinstellungen greifen still — mit diesen Folgen:
-
-| Variable | Voreinstellung | Warum das wichtig ist |
-|---|---|---|
-| `STT_BACKEND` | `whispercpp` | `WHISPER_MODELL`/`WHISPER_COMPUTE` aus der Vorlage gelten **nur** für `faster`. In der Voreinstellung werden sie ignoriert. |
-| `WHISPERCPP_MODELL` | `~/whisper-models/ggml-large-v3-turbo.bin` | Liegt das Modell woanders, bricht die Erkennung ab. |
-| `WHISPERCPP_BIN` | `whisper-cli` | Muss im `PATH` liegen (Homebrew `whisper-cpp`). |
-| `WHISPER_THREADS` | `8` | Auf kleineren Maschinen herunterdrehen. |
-| `NEXTCLOUD_DECK` | *aus* | **Häufigste Verwirrung:** `DECK_TEILEN` und `DECK_STAPEL` stehen in der Vorlage, der Hauptschalter nicht. Ohne `NEXTCLOUD_DECK=1` entsteht kein Board. |
-| `NEXTCLOUD_USERID` | = `NEXTCLOUD_USER` | Bei E-Mail-Login weicht die interne User-ID ab, dann zeigt der WebDAV-Pfad ins Leere. |
-| `FREESWITCH_LOG` | `/opt/homebrew/var/log/freeswitch/…` | Apple-Silicon-Homebrew. Auf Intel-Macs `/usr/local/...`. Sonst bleiben die Telefon-Ereignisse im Leitstand leer. |
-| `ABLAGE_LOKAL`, `TELEFON_ORDNER` | im Projektordner | Für eine Ablage auf einem anderen Volume hier umbiegen. |
-| `WHISPER_PROMPT` | Praxis-Kontext | Steuert, wie zuverlässig Namen und Rufnummern erkannt werden. Bei anderem Einsatzzweck anpassen. |
-
-`starten.sh` nutzt außerdem `FS_ETC` (Voreinstellung `/opt/homebrew/etc/freeswitch`)
-und `FS_PORT` (`8099`, weil 8021 auf dem Ursprungsrechner belegt war). Auf einer
-frischen Maschine ist 8021 meist frei — dann entweder `FS_PORT=8021` setzen oder
-den Port in `autoload_configs/event_socket.conf.xml` auf 8099 lassen. Beides
-muss zusammenpassen, sonst antwortet `fs_cli` nicht.
-
-## 4. Prüfleiter — in dieser Reihenfolge
-
-1. **Erkennung:** `python3 -m pipe.process_call test/termin.wav --nummer 0…`
- → `meta.json` enthält ein plausibles `transkript`.
-2. **Kategorisierung:** `test/rezept.wav` → `kategorie: rezept`,
- `test/notfall.wav` → `dringlichkeit: notfall`. Bleibt `kategorie` leer,
- läuft Ollama nicht oder das Modell fehlt.
-3. **Name und Rückrufnummer** stehen als eigene Felder in `meta.json` — nicht
- nur im Fließtext von `anliegen_kurz` (siehe Falle 1 unten).
-4. **Nextcloud:** Zugangsdaten eintragen, erneut einen Anruf verarbeiten, dann
- `python3 -m pipe.resync` — meldet 0 Fehler.
-5. **Deck:** `NEXTCLOUD_DECK=1` **und** `DECK_TEILEN` setzen. Ohne Freigabe legt
- die Pipe Karten an, die Praxis sieht ein leeres Board.
-6. **Leitstand:** `python3 -m pipe.server`, dann .
-7. **Telefonie** zuletzt: `./telefon/starten.sh` prüft selbst vor und bricht mit
- Klartext ab.
-
-## 5. Fallen, die uns beim ersten Volltest getroffen haben
-
-1. **Name und Rückrufnummer gingen still verloren.** Beide Felder fehlten in der
- `required`-Liste des JSON-Schemas; das Modell ließ sie einfach weg, obwohl es
- sie kannte. Behoben — aber beim Anpassen des Schemas in `pipe/categorize.py`
- sofort wieder einbaubar. Immer gegen `test/notfall.wav` gegenprüfen.
-2. **WebDAV ohne Wiederholung.** Ein einzelner träger Antwortlauf schickt den
- Anruf in den manuellen Nachsync. Jetzt 3 Versuche mit Backoff, Timeout 60 s.
-3. **Ordnername aus der geratenen statt der CLIP-Nummer.** Die Nummer kommt aus
- der Telefonanlage, nicht aus dem Transkript.
-4. **Stumme ffmpeg-/whisper-Fehler.** Jetzt mit Klartext — bei eigenen
- Änderungen nicht wieder auf nackte `CalledProcessError` zurückfallen.
-5. **Telephone.app streitet um dieselbe SIP-Registrierung.** Vor dem Start
- beenden; `starten.sh` prüft das.
-6. **Rotierende Provider-Adressen.** Löst der SIP-Hostname auf mehrere IPs auf,
- bricht die Registrierung dauernd ab. `SIP_PROXY` auf einen Host setzen, der
- auf genau eine IP zeigt.
-7. **Leerzeichen im Projektpfad brechen die Aufnahme.** FreeSWITCHs `record`
- zerlegt seine Argumente an Leerzeichen (`record
- `). Liegt das Projekt z. B. unter
- `~/Desktop/Server AI/…`, landet die Aufnahme als `…/Server.PCMA` neben dem
- Projekt, der Eingang bleibt leer und es sieht aus wie ein Watcher-Fehler —
- die Ansage läuft ja. `einrichten.sh` bricht deshalb bei Leerzeichen ab und
- nennt den Symlink-Ausweg. Getroffen auf der zweiten Maschine, 23.08.2026.
-8. **FreeSWITCH liest aus dem Cellar**, wenn `-conf` fehlt — dann überschreibt
- jedes Homebrew-Upgrade Trunk und Dialplan.
-
-## 6. Was bewusst noch fehlt (vor Produktivbetrieb klären)
-
-- **Notfall löst keinen Alarm aus.** `dringlichkeit: notfall` bekommt ein rotes
- Label und steht im Dashboard oben — kein Ton, keine Push, keine Mail. Eine
- Pipe, die Notfälle stumm ablegt, ist für eine Praxis gefährlicher als gar
- keine. Das ist der nächste Schritt, vor der Telefonie.
-- **Keine Löschfristen**, keine Verschlüsselung at rest.
-- **Leitstand im Netz** (`LEITSTAND_HOST=0.0.0.0`) verlangt `LEITSTAND_PASS` —
- die Seiten zeigen Transkripte und Aufnahmen von Patienten.
-- **Ansage** nennt 112 und weist auf die Aufzeichnung hin. Beides ist bei einer
- Arztpraxis Pflicht und darf beim Umtexten nicht wegfallen.
-- **Mehrsprachigkeit:** `WHISPER_SPRACHE=de` ist fest. Für EN/AR auf `auto`.
-
-## 7. Nützliche Rückmeldung
-
-Wenn auf der anderen Seite etwas abweicht, sind vor allem diese Punkte für uns
-hier interessant:
-
-- andere Hardware/OS-Version und wie lange ein Anruf dann braucht (hier ~24 s),
-- ob `qwen3:8b` bei 16 GB RAM neben laufender Telefonie noch passt,
-- jede Stelle, an der README oder `.env.example` nicht gereicht haben — die
- gehört dann dort hinein, nicht in diese Datei.
diff --git a/docs/agentone-screen-1.jpg b/docs/agentone-screen-1.jpg
deleted file mode 100644
index 52f9c6c..0000000
Binary files a/docs/agentone-screen-1.jpg and /dev/null differ
diff --git a/docs/logpy-agentone-1-1.jpg b/docs/logpy-agentone-1-1.jpg
new file mode 100644
index 0000000..4da4c82
Binary files /dev/null and b/docs/logpy-agentone-1-1.jpg differ
diff --git a/requirements.txt b/requirements.txt
index ff5a861..3e805da 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,3 +6,7 @@ piper-tts>=1.2
certifi
# Nur nötig für STT_BACKEND=faster; der Standard ist whisper.cpp.
faster-whisper>=1.2
+
+# Outbound-ESL-Bootstrap fuer die Pipecat-Testleitung (dialog/pipecat_bootstrap.py):
+# nimmt FreeSWITCHs socket()-Uebergabe an, macht answer()+uuid_audio_stream+park().
+greenswitch>=0.0.19
diff --git a/telefon/freeswitch/dw3_warten.xml.tpl b/telefon/freeswitch/dw3_warten.xml.tpl
new file mode 100644
index 0000000..f3b1471
--- /dev/null
+++ b/telefon/freeswitch/dw3_warten.xml.tpl
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/telefon/freeswitch/dw9_rufagent.xml.tpl b/telefon/freeswitch/dw9_rufagent.xml.tpl
new file mode 100644
index 0000000..7d10bf3
--- /dev/null
+++ b/telefon/freeswitch/dw9_rufagent.xml.tpl
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/telefon/freeswitch/einrichten.sh b/telefon/freeswitch/einrichten.sh
index 4da1b8c..85184b3 100755
--- a/telefon/freeswitch/einrichten.sh
+++ b/telefon/freeswitch/einrichten.sh
@@ -44,6 +44,8 @@ fuelle() { #
-e "s|@@SIP_PASS@@|$SIP_PASS|g" \
-e "s|@@SIP_DOMAIN@@|$SIP_DOMAIN|g" \
-e "s|@@SIP_PROXY@@|${SIP_PROXY:-$SIP_DOMAIN}|g" \
+ -e "s|@@DW3_DDI@@|${DW3_DDI:-}|g" \
+ -e "s|@@DW9_DDI@@|${DW9_DDI:-}|g" \
-e "s|@@PROJEKT@@|$PROJEKT|g" "$1" > "$2"
}
@@ -55,3 +57,20 @@ echo "Trunk -> $FS_ETC/sip_profiles/external/plusnet.xml (Rechte 600)"
echo "Dialplan -> $FS_ETC/dialplan/public/00_praxis_ab.xml"
echo "Ansage -> $PROJEKT/telefon/ansage.wav"
echo "Eingang -> $PROJEKT/telefon/eingang"
+
+if [ -n "${DW3_DDI:-}" ]; then
+ fuelle "$PROJEKT/telefon/freeswitch/dw3_warten.xml.tpl" "$FS_ETC/dialplan/public/00_dw3_warten.xml"
+ echo "DW3 -> $FS_ETC/dialplan/public/00_dw3_warten.xml (DDI: $DW3_DDI, 20s Wartezeit)"
+else
+ echo "DW3 -> uebersprungen (DW3_DDI leer in .env)"
+fi
+
+# DW9 (rufagent) -> Astra-Telefonagent. Dateiname "00_dw9_..." ist Absicht:
+# muss alphabetisch vor 00_praxis_ab.xml einsortieren, siehe
+# dw9_rufagent.xml.tpl fuer die Begruendung (2026-09-13 gefunden).
+if [ -n "${DW9_DDI:-}" ]; then
+ fuelle "$PROJEKT/telefon/freeswitch/dw9_rufagent.xml.tpl" "$FS_ETC/dialplan/public/00_dw9_rufagent.xml"
+ echo "DW9 -> $FS_ETC/dialplan/public/00_dw9_rufagent.xml (DDI: $DW9_DDI, -> Astra)"
+else
+ echo "DW9 -> uebersprungen (DW9_DDI leer in .env)"
+fi
diff --git a/telefon/starten.sh b/telefon/starten.sh
index caef1c8..e2b13f7 100755
--- a/telefon/starten.sh
+++ b/telefon/starten.sh
@@ -32,6 +32,19 @@ for log in telefon/autostart.log telefon/watcher.log telefon/leitstand.log telef
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"; }
@@ -79,7 +92,14 @@ else
# -rp: Echtzeit-Priorität. Auf einem Rechner, der nebenher Whisper und ein
# Sprachmodell laufen lässt, kommt FreeSWITCH sonst nicht rechtzeitig dran und
# das Audio stückelt - RTP will alle 20 ms ein Paket losschicken.
- freeswitch -nc -rp -nonatmap \
+ # -nonat: keine NAT-PMP/UPnP-Erkennung am Router (192.168.2.1:5351). Unter
+ # launchd (Watchdog) blockiert macOS' "Lokales Netzwerk"-Datenschutz jedes
+ # Senden ins LAN mit EPIPE; so frueh im Start ignoriert FreeSWITCH SIGPIPE
+ # noch nicht und stirbt 0,5 s nach dem Backgrounding - ohne Log, ohne
+ # Crash-Report. Aus einer Terminal-Shell passiert das nicht, deshalb lief es
+ # manuell. Der Router antwortet ohnehin nicht (nat_map status: UNKNOWN); die
+ # externe IP kommt per STUN (vars.xml), Trunks und Sockets brauchen kein LAN.
+ freeswitch -nc -rp -nonat -nonatmap \
-conf "$FS_ETC" \
-log /opt/homebrew/var/log/freeswitch \
-db /opt/homebrew/var/lib/freeswitch/db \
@@ -123,11 +143,17 @@ if [ "${status:-}" = "UP" ]; then
sleep 2
tail -1 telefon/leitstand.log
fi
- if pgrep -f "[p]ipe.monitor" >/dev/null; then
- echo "Stoerungswache laeuft bereits."
- else
- nohup python3 -u -m pipe.monitor >> telefon/monitor.log 2>&1 &
- echo "Stoerungswache gestartet (Log: telefon/monitor.log)"
+ # Pipecat-Testleitung (Nebenstelle 7501) - nur wenn konfiguriert, siehe
+ # dialog/pipecat_bootstrap.py und dialplan/default/06_agentzwei_pipecat_test.xml.
+ if [ -n "${ASTRA_TELEFON_WS_URL:-}" ]; then
+ if pgrep -f "[d]ialog.pipecat_bootstrap" >/dev/null; then
+ echo "Pipecat-Bootstrap laeuft bereits."
+ else
+ # Aus der Projekt-venv: greenswitch/gevent fehlen im System-python3,
+ # dort bricht der Bootstrap sofort mit ModuleNotFoundError ab.
+ nohup .venv/bin/python -u -m dialog.pipecat_bootstrap >> telefon/pipecat_bootstrap.log 2>&1 &
+ echo "Pipecat-Bootstrap gestartet (Log: telefon/pipecat_bootstrap.log)"
+ fi
fi
sleep 2
echo