Compare commits

...

14 commits
v1.1.0 ... main

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ovr91y2VK8bYYKPimcX68
2026-09-01 02:53:05 +02:00
Jeuner
b3d5e82958 docs: MITGABE.md entfernt (veraltet)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHyAcP2JsKWKN9wbcwVjB9
2026-08-31 08:47:27 +02:00
Jeuner
4d46d846ea docs: veralteten MITGABE.md-Hinweis aus README entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHyAcP2JsKWKN9wbcwVjB9
2026-08-31 08:46:28 +02:00
Jeuner
069b5514d5 docs: Screenshot aktualisiert (agentone-screen-1 -> logpy-agentone-1-1)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHyAcP2JsKWKN9wbcwVjB9
2026-08-31 08:44:17 +02:00
14 changed files with 696 additions and 138 deletions

View file

@ -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

289
HANDOFF.md Normal file
View file

@ -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 `<X-PRE-PROCESS cmd="include" data="public/*.xml"/>`
**alphabetisch** ein, und `00_praxis_ab.xml`s Catch-all kommt alphabetisch
VOR `00_rufagent_astra.xml` (`p` < `r`) und beendet die Dialplan-Auswertung,
bevor die eigene Regel überhaupt geladen wird.
**Fix:** Datei umbenannt zu `00_dw9_rufagent.xml` (`d` < `p`, sortiert direkt
hinter `00_dw3_warten.xml` und vor `00_praxis_ab.xml`). Als Vorlage ins Repo
übernommen: `telefon/freeswitch/dw9_rufagent.xml.tpl` + `DW9_DDI` in `.env`
+ neuer Block in `telefon/freeswitch/einrichten.sh` (analog zum
DW3-Block) — die Live-Config kommt jetzt wieder komplett aus dem Repo,
reproduzierbar per `einrichten.sh`.
**Lehre für weitere Durchwahlen:** Jede neue `dialplan/public/*.xml`-Regel,
die vor `00_praxis_ab.xml` greifen soll, MUSS einen Dateinamen bekommen, der
alphabetisch vor `p` einsortiert (Konvention: `00_dwN_<name>.xml`, `N` als
Ziffer direkt nach `00_dw`). Sonst wird sie stillschweigend nie erreicht -
kein Fehler, kein Log-Eintrag, sie taucht im Dialplan-Trace einfach nicht auf.
### 2. Telephone.app kann eine DDI-Registrierung stehlen
Telephone.app (macOS-Softphone) lief auf minim4-1 mit den SIP-Zugangsdaten
einer der Fonial-Durchwahlen für manuelles SIP-Debugging (siehe
`/tmp/telephone_call.pcap`-Mitschnitt dort). Läuft es gleichzeitig mit
FreeSWITCHs eigener Registrierung derselben Durchwahl, ist nicht
deterministisch, wer den Anruf bekommt - siehe auch die Warnung in
`telefon/starten.sh` ("Telephone.app LAEUFT - beenden, sonst
Registrierungskonflikt"). Vor jedem externen Testanruf prüfen:
`pgrep -xl Telephone` auf der Maschine, auf der es installiert ist (hier:
minim4-1, nicht minim4-2!), und bei Bedarf beenden.
## GELÖST 2026-09-13: keine fertige Antwort am Telefon
Realer externer Testanruf (2026-09-13, ~12:14 Uhr) mit echtem Sprecher:
- Audio kommt sauber an (Peak ~3000, kein Stille-Artefakt wie bei
Loopback/Selbstanruf-Tests).
- STT transkribiert korrekt: "kannst du mich hören?", danach "Hallo."
- Astra spielt beide Male nur den Zwischenbescheid **"Einen Moment bitte."**
- Danach läuft die eigentliche LLM-Antwort (`granite4.2:8b` über Ollama,
`NativeOllamaService`) im Astra-Log als `'phase': 'running'` weiter,
wird aber nie fertig:
- 1. Versuch: nach 4814 ms durch neue erkannte Nutzer-Sprache
(`VADUserTurnStartStrategy`) abgebrochen (`'phase': 'cancelled'`) -
der Anrufer hat vermutlich aus Ungeduld nochmal "Hallo" gesagt.
- 2. Versuch: nach 6002 ms abgebrochen, weil der Anrufer aufgelegt hat
(`Anruf beendet` direkt danach im Log, keine erneute Nutzer-Sprache).
- In beiden Fällen: **keine einzige echte Antwort hat es bis zur TTS
geschafft**, nur der Zwischenbescheid.
**Hypothese:** Kein Barge-in/Echo-Problem (anders als in der vorherigen
Handoff-Version vermutet) - hier ist die reale Sprache eindeutig neu, kein
Echo-Artefakt. Verdacht: `granite4.2:8b` per Ollama ist für die
Telefon-Pipeline schlicht zu langsam (>5s ohne fertige Antwort), der
Anrufer gibt vorher auf. Zu prüfen morgen:
- Ollama-Antwortzeit isoliert messen (gleicher Prompt, gleiches Modell,
ohne Telefon-Pipeline drumherum) - liegt es am Modell/Backend oder an
Rechenlast durch parallel laufende STT/TTS-Modelle auf demselben Mac?
- Ob `ASTRA_OLLAMA_URL`/`ASTRA_OLLAMA_BACKENDS` für die Telefonie-Pipeline
auf ein schnelleres/entlastetes Backend zeigen sollte (siehe README
"Ollama-Backend wählen").
- Ob ein kürzerer/schnellerer Zwischenbescheid-Rhythmus (z. B. alle 2-3s
ein weiteres "Moment noch") das gefühlte Hängen entschärfen würde, während
am eigentlichen Latenzproblem gearbeitet wird.
**Root Cause bestätigt:** `granite4.2:8b` war zwischen dem Server-Start
(01:50 Uhr) und dem Testanruf (12:14 Uhr) aus Ollama verdrängt worden, trotz
`keep_alive: -1` in `astra/core.py::build_request` - `ollama ps` zeigte
davor `"models": []`. Direkt gemessen: kalt `load_duration` ~12,2s, warm
Gesamtdauer <0,9s. Der Anrufer wurde ungeduldig/legte auf, lange bevor die
eigentliche Generierung (nach dem Laden nur ~1s) fertig war.
**Fix:** `astra/server.py::telefon_inbound` feuert jetzt beim
Verbindungsaufbau parallel zur Begrüßung einen Wegwerf-Generate-Aufruf an
Ollama (`_warm_ollama`, fire-and-forget, Fehler werden ignoriert) - der
Kaltstart-Tax landet dadurch während die Begrüßung läuft, nicht während der
ersten echten Antwort. Verifiziert mit echtem externen Testanruf: LLM-Zeit
1355 ms, vollständige Antwort kam durch, keine Abbrüche mehr. Commit
`a66629a` in `martin-voice-interface`.
**Falls das Problem wiederkehrt** (z. B. weil ein anderer Ollama-Verbraucher
zwischen Anrufen ein anderes Modell lädt und `granite4.2:8b` trotzdem wieder
verdrängt wird): `curl http://127.0.0.1:11434/api/ps` vor einem Testanruf
prüfen, ob das Modell geladen ist.
## Weiterhin offen (aus der vorherigen Handoff-Fassung, unverändert)
### Sitzungs-Hänger-Bug (Astra `busy: true` bleibt hängen)
Noch nicht erneut beobachtet/verifiziert heute, aber nicht aktiv behoben -
weiter im Auge behalten: `sessions`-Gate in `astra/server.py` könnte bei
einem nicht sauber geschlossenen WebSocket (z. B. hartes `hupall`) hängen
bleiben, siehe `run_telephony_call`/`_run_pipeline`s Cleanup-Pfad.
## Werkzeuge/Referenzen
- **Test-Nummer** (externe echte Rufnummer, Astra-Testleitung):
**+49 2103 78916179** (Durchwahl 9 / "rufagent" im Fonial-Portal). Vor dem
Test: Telephone.app auf minim4-1 beenden, falls es läuft
(`pgrep -xl Telephone`).
- **Loopback (ext. 7501, kein echtes Telefon)**: nur zum Prüfen der
Signalisierung/Verkabelung geeignet, liefert IMMER Stille (Peak ~1) - kein
Ersatz für einen echten Audiotest.
`ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'originate loopback/7501 &park()'"`
- **Selbstanruf** (`dialog.anrufen 2`): unzuverlässig, liefert ebenfalls nur
Stille (bestätigt 2026-09-13) - nicht mehr nötig jetzt, wo DW9 extern
erreichbar ist.
- **Nach jedem Testanruf auflegen**:
`ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'hupall NORMAL_CLEARING'"`
- **FreeSWITCH-Dialplan neu laden** (nach Änderungen in `dialplan/public/`,
kein Neustart nötig):
`ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'reloadxml'"`
- **Astras Live-Log**: `/private/tmp/astra_server.log` auf minim4-1 (NICHT
`.runtime/server.log` - das ist ein alter, verwaister Lauf). Bei Zweifel,
wohin ein laufender Prozess loggt: `lsof -p <pid> -a -d 0,1,2`.
- **Bootstrap-Log**: `fonagent-one/telefon/pipecat_bootstrap.log` auf
minim4-2.
- **ESL-Port** auf minim4-2 ist **8022**, nicht der Standard 8021.
- **Astra neu starten** (siehe vorherige Handoff-Fassung im Git-Log für den
vollständigen Befehl mit `ASTRA_TELEFON_SECRET`/`ASTRA_LOG_LEVEL`).
## Zugangsdaten/Config
- `.env` auf minim4-2 im Projekt (gitignored) — SIP-Zugänge für `plusnet`,
`agentzwei`, `agentzwei_sbc`, `ASTRA_TELEFON_WS_URL`, jetzt auch
`DW9_DDI=49210378916179`.
- `ASTRA_TELEFON_SECRET` — nur als Laufzeit-Env-Var gesetzt, nicht in einer
Datei auf minim4-1 abgelegt. Muss auf beiden Seiten übereinstimmen.
## GELÖST 2026-09-13 (Folgefehler): Begrüßung komplett stumm nach dem Cap-Fix
Der Cap-Fix oben (Puffer auf 12s angehoben) legte einen zweiten, tieferen Bug
frei: `TTSStoppedFrame` erreicht `serialize()` in diesem Transport **nie** -
per Frame-Type-Tracing bestätigt, `FastAPIWebsocketOutputTransport` reicht an
den Serializer nur `OutputAudioRawFrame` und (bei Barge-in) `InterruptionFrame`
durch, `TTSStoppedFrame` wird intern fürs "Bot hat aufgehört zu sprechen"
verbraucht und nie weitergereicht. Der `if isinstance(frame, TTSStoppedFrame)`-
Zweig war also von Anfang an toter Code - geflusht wurde bisher ausschließlich
über die (zu klein bemessene) Cap. Mit der korrigierten, viel höheren Cap
erreichte eine normal lange Antwort (3-4s) diese nie mehr - und ohne den
(toten) TTSStoppedFrame-Pfad flusste dann gar nichts mehr: Astra loggte
Begrüßung + Transkript ganz normal, aber bei FreeSWITCH kam kein einziges
Playback-Event an.
**Fix:** Flush jetzt über ein Inaktivitäts-Timeout (300ms ohne neuen
Audio-Frame) statt über `TTSStoppedFrame`. Da der Idle-Flush aus einem
Hintergrund-Task feuert (nicht aus `serialize()`s Rückgabewert), braucht er
einen eigenen Sendeweg - der Serializer nimmt jetzt zusätzlich zu `notify`
ein `send`-Callable (`websocket.send_text`). Commit `0a0c8d5` in
`martin-voice-interface`.
Live verifiziert (Loopback-Test): 2,88s gesendet, FreeSWITCH spielt 3,02s
vollständig ab ("done playing file") - keine Stille, kein Abschneiden mehr.
## GELÖST 2026-09-13 (weitere Folgefehler): zu lange Antworten + hackige Wiedergabe
Nach dem Idle-Flush-Fix zwei weitere Probleme live gemeldet:
1. **Antworten zu lang** - der Prompt allein ("1-2 kurze Sätze") wurde vom
Modell ignoriert, Antworten liefen auf 2-3 Sätze und lang genug, um die
12s-Puffergrenze zu reißen. Fix: harte `num_predict`-Grenze
(`Settings.telefon_max_tokens`, Standard 40, `ASTRA_TELEFON_MAX_TOKENS`
env-einstellbar) statt nur einer Bitte im Prompt. Commit `4e6f266`.
2. **Wiedergabe "hackig"** - der 300ms-Idle-Flush (siehe vorheriger Eintrag)
feuerte bei Mehrsatz-Antworten fast immer VOR dem nächsten Satz, weil die
Pause zwischen zwei TTS-Satz-Chunks (das LLM muss den nächsten Satz erst
zu Ende streamen) regelmäßig über 300ms liegt - jeder Satz kam so als
eigene, einzeln abgespielte Nachricht statt als eine zusammenhängende
Antwort. Fix: `flush_now()` auf dem Serializer, ausgelöst über
`run_telephony_call`s `notify()`-Hook genau dann, wenn die Pipeline
meldet, dass der Bot wirklich fertig gesprochen hat
(`BotStoppedSpeakingFrame` via den `'state': 'listening'`-Callback) - das
ist das einzige verlässliche "Antwort fertig"-Signal, da `TTSStoppedFrame`
selbst `serialize()` nie erreicht (siehe voriger Eintrag). Der
Idle-Timeout ist jetzt nur noch Fallback (4s statt 300ms). Commit
`42acca4`.
Live verifiziert: Begrüßung flusht jetzt ~2ms nach echtem Sprechende (statt
vorher 300ms Verzögerung), vollständig, keine Aufteilung.
**Offen für nächsten Test:** ob Mehrsatz-Antworten jetzt tatsächlich flüssig
klingen (nur mit Loopback getestet, das produziert keine echte Sprache für
einen mehrteiligen Dialog) - braucht einen echten Anruf mit echtem Gespräch.
## GELÖST 2026-09-13 (weiterer Folgefehler): Anruf trennt nach ~50s ohne Antwort
Erster echter Mehrsatz-Dialog lief mehrere Runden gut (kurze Antworten,
zusammenhängende Wiedergabe) - dann zweimal hintereinander: VAD hat ausgelöst
(`min_volume=0.3` reagiert absichtlich auch auf leise Signale, siehe
vorheriger Eintrag), aber Nemotron hat für die ganze Runde nichts
transkribiert (`Nemotron result: final, 0 characters`). Ein leeres Transkript
erreicht `LLMUserAggregator` nie als echte Nutzer-Nachricht - keine
LLM-Anfrage, keine Antwort, keine Rückmeldung. Für den Anrufer wirkt das wie
eine tote Leitung; nach zwei solchen Runden hat er aufgelegt.
**Fix:** `NemotronSTTService` bekommt jetzt optional `no_speech_phrases`
(`astra.services.NO_SPEECH_PHRASES`, z. B. "Wie bitte?", "Kannst du das
wiederholen?") und spricht bei leerem finalem Transkript eine davon über
`TTSSpeakFrame`. Nur für die Telefonie verdrahtet (Browser hat schon einen
sichtbaren "listening"-Zustand). Commit `c8946d0`.
**Noch offen:** ob `min_volume=0.3` grundsätzlich zu locker ist (löst zu oft
auf Nicht-Sprache aus) - dieser Fix behandelt nur das Symptom (Anrufer bekommt
immer eine Reaktion), nicht die Ursache. Bei wiederholtem Auftreten:
`ASTRA_TELEFON_VAD_MIN_VOLUME` schrittweise erhöhen und beobachten, ob echte
leise Sprache dann noch erkannt wird.

View file

@ -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

0
dialog/__init__.py Normal file
View file

68
dialog/anrufen.py Normal file
View file

@ -0,0 +1,68 @@
"""Outbound-Call-Trigger fuer die Pipecat-Testleitung.
Ruft eine Nummer ueber den Plusnet-Trunk an und uebergibt sie nach Abheben an
dieselbe Bruecke wie die interne Testnebenstelle 7501
(dialog/pipecat_bootstrap.py, Port 8095) - answer()+uuid_audio_stream+park()
laeuft dort identisch, unabhaengig davon ob der Anruf rein- oder rausgeht. Der
Dialplan-Umweg (Nebenstelle 7501) entfaellt hier: `&socket(...)` haengt den
ausgehenden Kanal direkt nach Abheben an den Bootstrap, ohne Dialplan-Lookup.
Nutzung: python3 -m dialog.anrufen <Nummer>
"""
import os
import sys
import greenswitch
FS_HOST = "127.0.0.1"
FS_PORT = int(os.environ.get("FS_PORT", "8022"))
FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon")
BOOTSTRAP_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095"))
# DW9 ("rufagent") - the only account confirmed capable of outbound so far.
# Needs "proxy" (R-URI domain, must be sip.plusnet.de - Plusnet 403s otherwise)
# split from "outbound-proxy" (actual packet routing, sbc.sip.plusnet.de) in
# its gateway XML - see sip_profiles/external/agentzwei_sbc.xml.
GATEWAY = os.environ.get("ANRUF_GATEWAY", "agentzwei_sbc")
CALLER_ID = os.environ.get("ANRUF_CALLER_ID", "+49210378916179")
def normalisiere(nummer: str) -> str:
"""Wie pipe/kontakte.py: nur Ziffern, Deutschland-Annahme 0 -> 49,
kein '+' - so erwartet es der Trunk auch fuer DW3_DDI in der .env."""
ziffern = "".join(zeichen for zeichen in nummer if zeichen.isdigit() or zeichen == "+")
if ziffern.startswith("+"):
return ziffern[1:]
if ziffern.startswith("00"):
return ziffern[2:]
if ziffern.startswith("0"):
return "49" + ziffern[1:]
return ziffern
def main():
if len(sys.argv) != 2:
sys.exit("Nutzung: python3 -m dialog.anrufen <Nummer>")
ziel = normalisiere(sys.argv[1])
# The &socket(...) part MUST stay in single quotes: originate splits its
# arguments on spaces, so an unquoted "&socket(127.0.0.1:8095 async full)"
# reaches the channel as plain "socket(127.0.0.1:8095)" - "async full"
# silently dropped (seen in freeswitch.log's own EXECUTE line, 2026-09-13).
# Without "full", mod_event_socket refuses every command past sendmsg on
# an outbound listener, so pipecat_bootstrap's CUSTOM-event subscription
# ("event plain CUSTOM mod_audio_stream::play") is answered with
# "-ERR command not found" and no audio is ever played back.
befehl = (
f"originate {{origination_caller_id_number={CALLER_ID}}}"
f"sofia/gateway/{GATEWAY}/{ziel} "
f"'&socket({FS_HOST}:{BOOTSTRAP_PORT} async full)'"
)
print(f"Rufe {ziel} an ueber: {befehl}")
fs = greenswitch.InboundESL(host=FS_HOST, port=FS_PORT, password=FS_ESL_PASSWORD)
fs.connect()
antwort = fs.send(f"api {befehl}")
print(antwort.data)
if __name__ == "__main__":
main()

187
dialog/pipecat_bootstrap.py Normal file
View file

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

View file

@ -1,129 +0,0 @@
# Mitgabe für die Einrichtung auf einem zweiten Rechner
Diese Datei ergänzt README (Einrichtung) und HANDOFF (Stand). Sie enthält das,
was auf der anderen Seite nicht im Repository ankommt: die Erfahrungswerte aus
dem ersten Volltest, die Stellen, an denen die Vorlagen unvollständig sind, und
die Reihenfolge, in der man prüft, ob es wirklich läuft.
## 1. Was im Repo *nicht* liegt
| Fehlt | Woher |
|---|---|
| `.env` | aus `.env.example` kopieren, Werte eintragen (Abschnitt 3) |
| Whisper-Modell (~1,6 GB) | `~/whisper-models/ggml-large-v3-turbo.bin`, Download siehe README |
| Ollama-Modell (~5,2 GB) | `ollama pull qwen3:8b` |
| Piper-Stimme | `~/piper-voices/…`, nur für die Ansage (Stufe 2) nötig |
| `ablage/`, `telefon/eingang/`, `dashboard.html` | Anruf- und Patientendaten, bleiben absichtlich lokal |
| FreeSWITCH-Konfiguration mit echtem SIP-Passwort | `telefon/freeswitch/einrichten.sh` schreibt sie aus der `.env` |
Alle Namen, Rufnummern und Beispieldaten im Repo sind erfunden. „Praxis
Musterhausen" ist Platzhalter — auch im Screenshot.
## 2. Der kurze Weg: Stufe 1 ohne Telefonie
Telefonie (FreeSWITCH, SIP-Trunk) ist für die Erprobung nicht nötig und macht
die meiste Arbeit. Für einen ersten Durchlauf reicht:
```bash
brew install ffmpeg whisper-cpp
brew install ollama && ollama serve &
ollama pull qwen3:8b
mkdir -p ~/whisper-models && cd ~/whisper-models
curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin
cd ~/praxis-telefon-agent && cp .env.example .env
python3 -m pipe.process_call test/rezept.wav --nummer 021031234567
```
Läuft das durch (~25 s), liegt das Ergebnis unter
`ablage/JJJJ-MM-TT/HH-MM-SS_021031234567/`. Nextcloud, Deck, Leitstand und
Telefonie sind alle abschaltbar und kommen erst danach dran.
## 3. Was die `.env.example` verschweigt
Die Vorlage deckt nicht alle Schalter ab, die `pipe/config.py` liest. Die
Voreinstellungen greifen still — mit diesen Folgen:
| Variable | Voreinstellung | Warum das wichtig ist |
|---|---|---|
| `STT_BACKEND` | `whispercpp` | `WHISPER_MODELL`/`WHISPER_COMPUTE` aus der Vorlage gelten **nur** für `faster`. In der Voreinstellung werden sie ignoriert. |
| `WHISPERCPP_MODELL` | `~/whisper-models/ggml-large-v3-turbo.bin` | Liegt das Modell woanders, bricht die Erkennung ab. |
| `WHISPERCPP_BIN` | `whisper-cli` | Muss im `PATH` liegen (Homebrew `whisper-cpp`). |
| `WHISPER_THREADS` | `8` | Auf kleineren Maschinen herunterdrehen. |
| `NEXTCLOUD_DECK` | *aus* | **Häufigste Verwirrung:** `DECK_TEILEN` und `DECK_STAPEL` stehen in der Vorlage, der Hauptschalter nicht. Ohne `NEXTCLOUD_DECK=1` entsteht kein Board. |
| `NEXTCLOUD_USERID` | = `NEXTCLOUD_USER` | Bei E-Mail-Login weicht die interne User-ID ab, dann zeigt der WebDAV-Pfad ins Leere. |
| `FREESWITCH_LOG` | `/opt/homebrew/var/log/freeswitch/…` | Apple-Silicon-Homebrew. Auf Intel-Macs `/usr/local/...`. Sonst bleiben die Telefon-Ereignisse im Leitstand leer. |
| `ABLAGE_LOKAL`, `TELEFON_ORDNER` | im Projektordner | Für eine Ablage auf einem anderen Volume hier umbiegen. |
| `WHISPER_PROMPT` | Praxis-Kontext | Steuert, wie zuverlässig Namen und Rufnummern erkannt werden. Bei anderem Einsatzzweck anpassen. |
`starten.sh` nutzt außerdem `FS_ETC` (Voreinstellung `/opt/homebrew/etc/freeswitch`)
und `FS_PORT` (`8099`, weil 8021 auf dem Ursprungsrechner belegt war). Auf einer
frischen Maschine ist 8021 meist frei — dann entweder `FS_PORT=8021` setzen oder
den Port in `autoload_configs/event_socket.conf.xml` auf 8099 lassen. Beides
muss zusammenpassen, sonst antwortet `fs_cli` nicht.
## 4. Prüfleiter — in dieser Reihenfolge
1. **Erkennung:** `python3 -m pipe.process_call test/termin.wav --nummer 0…`
`meta.json` enthält ein plausibles `transkript`.
2. **Kategorisierung:** `test/rezept.wav``kategorie: rezept`,
`test/notfall.wav``dringlichkeit: notfall`. Bleibt `kategorie` leer,
läuft Ollama nicht oder das Modell fehlt.
3. **Name und Rückrufnummer** stehen als eigene Felder in `meta.json` — nicht
nur im Fließtext von `anliegen_kurz` (siehe Falle 1 unten).
4. **Nextcloud:** Zugangsdaten eintragen, erneut einen Anruf verarbeiten, dann
`python3 -m pipe.resync` — meldet 0 Fehler.
5. **Deck:** `NEXTCLOUD_DECK=1` **und** `DECK_TEILEN` setzen. Ohne Freigabe legt
die Pipe Karten an, die Praxis sieht ein leeres Board.
6. **Leitstand:** `python3 -m pipe.server`, dann <http://127.0.0.1:8088>.
7. **Telefonie** zuletzt: `./telefon/starten.sh` prüft selbst vor und bricht mit
Klartext ab.
## 5. Fallen, die uns beim ersten Volltest getroffen haben
1. **Name und Rückrufnummer gingen still verloren.** Beide Felder fehlten in der
`required`-Liste des JSON-Schemas; das Modell ließ sie einfach weg, obwohl es
sie kannte. Behoben — aber beim Anpassen des Schemas in `pipe/categorize.py`
sofort wieder einbaubar. Immer gegen `test/notfall.wav` gegenprüfen.
2. **WebDAV ohne Wiederholung.** Ein einzelner träger Antwortlauf schickt den
Anruf in den manuellen Nachsync. Jetzt 3 Versuche mit Backoff, Timeout 60 s.
3. **Ordnername aus der geratenen statt der CLIP-Nummer.** Die Nummer kommt aus
der Telefonanlage, nicht aus dem Transkript.
4. **Stumme ffmpeg-/whisper-Fehler.** Jetzt mit Klartext — bei eigenen
Änderungen nicht wieder auf nackte `CalledProcessError` zurückfallen.
5. **Telephone.app streitet um dieselbe SIP-Registrierung.** Vor dem Start
beenden; `starten.sh` prüft das.
6. **Rotierende Provider-Adressen.** Löst der SIP-Hostname auf mehrere IPs auf,
bricht die Registrierung dauernd ab. `SIP_PROXY` auf einen Host setzen, der
auf genau eine IP zeigt.
7. **Leerzeichen im Projektpfad brechen die Aufnahme.** FreeSWITCHs `record`
zerlegt seine Argumente an Leerzeichen (`record <pfad> <zeitlimit>
<stille-schwelle> <stille-treffer>`). Liegt das Projekt z. B. unter
`~/Desktop/Server AI/…`, landet die Aufnahme als `…/Server.PCMA` neben dem
Projekt, der Eingang bleibt leer und es sieht aus wie ein Watcher-Fehler —
die Ansage läuft ja. `einrichten.sh` bricht deshalb bei Leerzeichen ab und
nennt den Symlink-Ausweg. Getroffen auf der zweiten Maschine, 23.08.2026.
8. **FreeSWITCH liest aus dem Cellar**, wenn `-conf` fehlt — dann überschreibt
jedes Homebrew-Upgrade Trunk und Dialplan.
## 6. Was bewusst noch fehlt (vor Produktivbetrieb klären)
- **Notfall löst keinen Alarm aus.** `dringlichkeit: notfall` bekommt ein rotes
Label und steht im Dashboard oben — kein Ton, keine Push, keine Mail. Eine
Pipe, die Notfälle stumm ablegt, ist für eine Praxis gefährlicher als gar
keine. Das ist der nächste Schritt, vor der Telefonie.
- **Keine Löschfristen**, keine Verschlüsselung at rest.
- **Leitstand im Netz** (`LEITSTAND_HOST=0.0.0.0`) verlangt `LEITSTAND_PASS`
die Seiten zeigen Transkripte und Aufnahmen von Patienten.
- **Ansage** nennt 112 und weist auf die Aufzeichnung hin. Beides ist bei einer
Arztpraxis Pflicht und darf beim Umtexten nicht wegfallen.
- **Mehrsprachigkeit:** `WHISPER_SPRACHE=de` ist fest. Für EN/AR auf `auto`.
## 7. Nützliche Rückmeldung
Wenn auf der anderen Seite etwas abweicht, sind vor allem diese Punkte für uns
hier interessant:
- andere Hardware/OS-Version und wie lange ein Anruf dann braucht (hier ~24 s),
- ob `qwen3:8b` bei 16 GB RAM neben laufender Telefonie noch passt,
- jede Stelle, an der README oder `.env.example` nicht gereicht haben — die
gehört dann dort hinein, nicht in diese Datei.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View file

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

View file

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

View file

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

View file

@ -44,6 +44,8 @@ fuelle() { # <vorlage> <ziel>
-e "s|@@SIP_PASS@@|$SIP_PASS|g" \
-e "s|@@SIP_DOMAIN@@|$SIP_DOMAIN|g" \
-e "s|@@SIP_PROXY@@|${SIP_PROXY:-$SIP_DOMAIN}|g" \
-e "s|@@DW3_DDI@@|${DW3_DDI:-}|g" \
-e "s|@@DW9_DDI@@|${DW9_DDI:-}|g" \
-e "s|@@PROJEKT@@|$PROJEKT|g" "$1" > "$2"
}
@ -55,3 +57,20 @@ echo "Trunk -> $FS_ETC/sip_profiles/external/plusnet.xml (Rechte 600)"
echo "Dialplan -> $FS_ETC/dialplan/public/00_praxis_ab.xml"
echo "Ansage -> $PROJEKT/telefon/ansage.wav"
echo "Eingang -> $PROJEKT/telefon/eingang"
if [ -n "${DW3_DDI:-}" ]; then
fuelle "$PROJEKT/telefon/freeswitch/dw3_warten.xml.tpl" "$FS_ETC/dialplan/public/00_dw3_warten.xml"
echo "DW3 -> $FS_ETC/dialplan/public/00_dw3_warten.xml (DDI: $DW3_DDI, 20s Wartezeit)"
else
echo "DW3 -> uebersprungen (DW3_DDI leer in .env)"
fi
# DW9 (rufagent) -> Astra-Telefonagent. Dateiname "00_dw9_..." ist Absicht:
# muss alphabetisch vor 00_praxis_ab.xml einsortieren, siehe
# dw9_rufagent.xml.tpl fuer die Begruendung (2026-09-13 gefunden).
if [ -n "${DW9_DDI:-}" ]; then
fuelle "$PROJEKT/telefon/freeswitch/dw9_rufagent.xml.tpl" "$FS_ETC/dialplan/public/00_dw9_rufagent.xml"
echo "DW9 -> $FS_ETC/dialplan/public/00_dw9_rufagent.xml (DDI: $DW9_DDI, -> Astra)"
else
echo "DW9 -> uebersprungen (DW9_DDI leer in .env)"
fi

View file

@ -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