mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-17 10:46:12 +02:00
Compare commits
39 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4903043e | ||
|
|
f42ac7ded5 | ||
|
|
6db89affd4 | ||
|
|
f785ab3bdb | ||
|
|
907f511c0d | ||
|
|
3f9cfe6b4f | ||
|
|
b24843ebe3 | ||
|
|
a797075dde | ||
|
|
7f5f9785d5 | ||
|
|
a0f1beb662 | ||
|
|
394a0faa27 | ||
|
|
3da0cd7ec9 | ||
|
|
b3d5e82958 | ||
|
|
4d46d846ea | ||
|
|
069b5514d5 | ||
|
|
17257e00c4 | ||
|
|
bdb5bd510a | ||
|
|
5c9f891c7e | ||
|
|
ab2bd949be | ||
|
|
be11baf52b | ||
|
|
37a5a78b15 | ||
|
|
184fb37593 | ||
|
|
b1cb3471a5 | ||
|
|
8621691054 | ||
|
|
8e54a58f13 | ||
|
|
07010fbfdf | ||
|
|
795dcf7eeb | ||
|
|
87621e5bec | ||
|
|
d39b360b7b | ||
|
|
2605710818 | ||
|
|
6e5fe59c53 | ||
|
|
09be478868 | ||
|
|
6458c36d85 | ||
|
|
be0252c907 | ||
|
|
de9c269e9c | ||
|
|
441883a073 | ||
|
|
abf54765d8 | ||
|
|
7dcb90826c | ||
|
|
af67ee89e3 |
38 changed files with 1755 additions and 352 deletions
38
.env.example
38
.env.example
|
|
@ -12,6 +12,12 @@ WHISPER_THREADS=8
|
|||
WHISPER_MODELL=medium
|
||||
WHISPER_COMPUTE=int8
|
||||
|
||||
# --- Branchen-Profil (Kategorien, Prompt, Sicherheitsnetz, Ansage) ---
|
||||
# Waehlt den Ordner unter profile/ - buendelt alles, was fachlich vom
|
||||
# Einsatzzweck abhaengt. Mitgeliefert: "praxis" (Hausarztpraxis, Default),
|
||||
# "aufzug-notdienst" (Beispiel/Vorlage). Siehe README "Kategorien & Branchen-Profil".
|
||||
PROFIL=praxis
|
||||
|
||||
# --- Kategorisierung ---
|
||||
# Backend: "ollama" (Default, lokal) oder "openrouter" (Cloud, nur zum Testen
|
||||
# ob ein staerkeres Modell besser extrahiert - Transkript verlaesst dabei den
|
||||
|
|
@ -45,12 +51,31 @@ NEXTCLOUD_ADRESSBUCH=contacts
|
|||
# Anrufe bleiben unangetastet, egal wie alt.
|
||||
LOESCHFRIST_TAGE=0
|
||||
|
||||
# --- Störungswache (pipe.monitor, eigener Prozess, siehe telefon/starten.sh) ---
|
||||
# Prüft die Dienste-Ampel (Telefon, Verarbeitung, Spracherkennung, Nextcloud)
|
||||
# und alarmiert lokal per macOS-Benachrichtigung + Ton, wenn niemand auf den
|
||||
# Leitstand schaut.
|
||||
MONITOR_TAKT_S=60
|
||||
MONITOR_WIEDERHOLUNG_MIN=15
|
||||
MONITOR_TON=Sosumi
|
||||
|
||||
# --- Telefonie (SIP-Zugang der Praxis, leer lassen = keine Telefonannahme) ---
|
||||
SIP_USER=
|
||||
SIP_DOMAIN=sip.plusnet.de
|
||||
SIP_PASS=
|
||||
SIP_PROXY=voice01.sip.plusnet.de
|
||||
|
||||
# --- Durchwahl 3: eigenes Endgeraet vor dem Anrufbeantworter klingeln lassen ---
|
||||
# Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim
|
||||
# Anrufbeantworter. Manche Provider (z.B. Fonial/Plusnet) klingeln bei einer
|
||||
# Durchwahl PARALLEL an mehreren registrierten Zielen - ohne Wartezeit
|
||||
# gewinnt AgentOne das Rennen immer, weil es sofort abnimmt (answer() ist die
|
||||
# erste Aktion im Dialplan). Die DDI kommt bei Plusnet nicht im
|
||||
# destination_number an (der ist bei diesem Trunk unbrauchbar), sondern im
|
||||
# Header X-ORIGINAL-DDI-URI - siehe telefon/freeswitch/dw3_warten.xml.tpl.
|
||||
# Nur Ziffern, kein "+".
|
||||
DW3_DDI=
|
||||
|
||||
# --- Ansage ---
|
||||
# piper = lokal, natuerlich, Modelle unter ~/piper-voices
|
||||
# say = lokal, macOS-Bordmittel (Rueckfall wenn Piper fehlt)
|
||||
|
|
@ -85,6 +110,13 @@ LEITSTAND_HOST=127.0.0.1
|
|||
LEITSTAND_PORT=8088
|
||||
LEITSTAND_USER=praxis
|
||||
LEITSTAND_PASS=
|
||||
# Weitere Konten zusaetzlich zum obigen - kommagetrennt "nutzer:passwort",
|
||||
# z.B. fuer ein zweites Team/einen zweiten Einsatzzweck neben "praxis".
|
||||
LEITSTAND_ZUGAENGE=
|
||||
|
||||
# Optional: geteiltes Geheimnis fuer signierte, 1h gueltige Testzugangslinks
|
||||
# (z.B. per Chatbot-Kommando ausgegeben). Leer = Feature aus.
|
||||
TESTZUGANG_SECRET=
|
||||
|
||||
# --- Telefonanlage (nur Stufe 2) ---
|
||||
# Event-Socket-Port von FreeSWITCH. Muss zu listen-port in
|
||||
|
|
@ -96,3 +128,9 @@ FS_ETC=/opt/homebrew/etc/freeswitch
|
|||
# Log von FreeSWITCH; der Leitstand liest daraus die Anruf-Ereignisse.
|
||||
# Apple Silicon: /opt/homebrew/... , Intel-Mac: /usr/local/...
|
||||
#FREESWITCH_LOG=/opt/homebrew/var/log/freeswitch/freeswitch.log
|
||||
|
||||
# Pipecat-Testleitung (dialog/pipecat_bootstrap.py, Nebenstelle 7501) -
|
||||
# WebSocket-Ziel fuer mod_audio_stream. token= muss ASTRA_TELEFON_SECRET
|
||||
# auf der Astra-Maschine entsprechen.
|
||||
ASTRA_TELEFON_WS_URL=wss://astra-host.example.ts.net/telefon/inbound?token=changeme
|
||||
PIPECAT_BOOTSTRAP_PORT=8095
|
||||
|
|
|
|||
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -1,5 +1,44 @@
|
|||
# Changelog
|
||||
|
||||
## v1.1.0 — 2026-08-30
|
||||
|
||||
Branchen-Profile machen das Projekt für andere Einsatzzwecke als die
|
||||
Hausarztpraxis konfigurierbar (z. B. 24h-Notdienst), dazu mehrere
|
||||
Leitstand-Härtungen und Aufräumarbeiten.
|
||||
|
||||
### Branchen-Profile (neu)
|
||||
- Projekt ist jetzt per `PROFIL`-Wahl für andere Einsatzzwecke konfigurierbar
|
||||
(Praxis, Aufzug-Notdienst, …) statt hart auf die Arztpraxis zugeschnitten
|
||||
- aktives Branchen-Profil im Leitstand/Dashboard sichtbar
|
||||
- README-Einleitung an den Profil-Umbau angepasst
|
||||
|
||||
### Leitstand & Zugriff
|
||||
- mehrere gleichzeitige Leitstand-Zugänge (`LEITSTAND_ZUGAENGE`), z. B. ein
|
||||
eigenes Notdienst-Konto neben dem Praxis-Konto
|
||||
- Abmelden-Button im Leitstand, zusätzlich per `?logoff` in der URL auslösbar
|
||||
- eingeloggter Nutzer im Header sichtbar; Verlauf zeigt jetzt, wer eine Karte
|
||||
verschoben hat
|
||||
- Modell-Dropdown listet lokale Ollama-Modelle live statt fest im Code
|
||||
- Dringlichkeitsfarben getauscht und zentralisiert; `/api/anrufe` und
|
||||
`/api/verlauf` werden jetzt gecacht (Performance)
|
||||
|
||||
### Testzugang & Sicherheit
|
||||
- signierte, 1h gültige Testzugangslinks für den Leitstand ("agent-one
|
||||
test"-Kommando)
|
||||
- Sicher-Modus bei Testzugang erzwungen, nicht abschaltbar
|
||||
- Rufnummern bei Testzugang serverseitig maskiert statt nur per CSS geblurrt
|
||||
(vorher im Rohdaten-Response sichtbar)
|
||||
|
||||
### Zuverlässigkeit
|
||||
- Störungswache (`pipe.monitor`) + Verschlüsselung-at-rest dokumentiert
|
||||
- Log-Rotation für Rohlogs + Aufräumen alter Verlauf-Archivkopien
|
||||
- Linux-Setup-Hinweis in der README ergänzt
|
||||
|
||||
### Sonstiges
|
||||
- echte Aufnahme statt Sprachsynthese als Ansage möglich (`ANSAGE_TTS=datei`)
|
||||
- Nextcloud-Profil, LinkedIn und Telefonnummer (internationales Format) im
|
||||
Footer
|
||||
|
||||
## v1.0.0 — 2026-08-25
|
||||
|
||||
Erste stabile Version. Läuft produktiv (mit Testdaten) auf macOS/Apple
|
||||
|
|
|
|||
323
HANDOFF.md
Normal file
323
HANDOFF.md
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
# Handoff — Live-Telefonagent (laufend, zuletzt aktualisiert 2026-09-14)
|
||||
|
||||
## Stand in einem Satz
|
||||
|
||||
Voller Weg funktioniert Ende-zu-Ende: externer Anruf auf Durchwahl 9 (Plusnet → FreeSWITCH → `dialog/pipecat_bootstrap.py` → WebSocket → Astra) erreicht Astra, und Astra liefert jetzt auch eine echte, fertige Antwort statt nur "Einen Moment bitte" (Fix 2026-09-13, siehe unten) - kein offener Blocker mehr bekannt.
|
||||
|
||||
## 2026-09-17 — Leitung stumm nach IP-Wechsel, Watchdog-Kinder sterben
|
||||
|
||||
**Symptom:** Durchwahl 9 (und die ganze Leitung) nahm nicht ab. Letzter
|
||||
eingehender Anruf im FreeSWITCH-Log: 2026-09-15 18:05. Trunks trotzdem
|
||||
REGED/UP.
|
||||
|
||||
### 1. Veraltete öffentliche IP im Contact
|
||||
|
||||
FreeSWITCH ermittelt die externe IP per STUN nur beim Laden der XML
|
||||
(`vars.xml`, `stun-set`). Contact an Plusnet war noch `9.246.125.72`,
|
||||
tatsächlich `217.142.18.120` (Zwangstrennung, 05:17 kurz 408/DOWN).
|
||||
Registrierung und Pings gehen weiter raus, eingehende INVITEs laufen ins
|
||||
Leere - kein Fehler im Log.
|
||||
|
||||
**Fix:** `starten.sh` vergleicht bei jedem Watchdog-Lauf
|
||||
`fs_cli -x 'stun stun.freeswitch.org'` mit `Ext-SIP-IP` des
|
||||
external-Profils; bei Abweichung und 0 Gesprächen `reloadxml` +
|
||||
`sofia profile external restart`. Nach einem IP-Wechsel ist die Leitung
|
||||
also bis zu 5 min stumm. Profil-Neustart von Hand getestet (Trunks sofort
|
||||
wieder REGED); der Abweichungsfall selbst noch nicht real ausgelöst.
|
||||
|
||||
### 2. launchd beendete alles, was starten.sh im Hintergrund startet
|
||||
|
||||
Vom Watchdog gestarteter Pipecat-Bootstrap band Port 8095 und war
|
||||
Sekunden später weg, ohne Log. Ursache: launchd räumt nach Jobende die
|
||||
Prozessgruppe ab. Betraf auch `pipe.monitor`/`pipe.watch`/`pipe.server` -
|
||||
Fix 3 vom 2026-09-14 (Störungswache zuerst) griff unter launchd also nie.
|
||||
FreeSWITCH überlebt, weil es sich selbst abkoppelt.
|
||||
|
||||
**Fix:** `<key>AbandonProcessGroup</key><true/>` in
|
||||
`~/Library/LaunchAgents/net.dillenberg.fonagent.plist` (liegt **nicht** im
|
||||
Repo), per `launchctl bootout`/`bootstrap` neu geladen. Verifiziert:
|
||||
Bootstrap per Watchdog gestartet, lebt nach Jobende weiter.
|
||||
|
||||
## 2026-09-14 — Leitung nach Neustart ~80 min tot: Watchdog bekam nichts hoch
|
||||
|
||||
**Symptom:** Nach einem Neustart des Mac mini (~12:39) blieb die Leitung
|
||||
(PROFIL=aufzug-notdienst) tot, bis ~13:58 von Hand gestartet wurde. Der
|
||||
launchd-Watchdog (`~/Library/LaunchAgents/net.dillenberg.fonagent.plist`,
|
||||
alle 5 min `starten.sh`) versuchte es 11-mal: jeweils "Backgrounding.",
|
||||
dann "fs_cli antwortet nicht", exit 1. `freeswitch.log` blieb unberührt,
|
||||
kein Crash-Report. Laut `autostart.log` hat der Watchdog FreeSWITCH **noch
|
||||
nie** erfolgreich gestartet - bis dahin lief es offenbar immer manuell.
|
||||
|
||||
Drei Ursachen, drei Fixes:
|
||||
|
||||
### 1. FreeSWITCH stirbt unter launchd an SIGPIPE (Commit `f785ab3`)
|
||||
|
||||
macOS' Datenschutz "Lokales Netzwerk" gilt für launchd-Prozesse, nicht für
|
||||
Terminal-Shells: jedes Senden an eine LAN-Adresse (192.168.x) endet dort mit
|
||||
`EPIPE`, Internet und 127.0.0.1 gehen. FreeSWITCH fragt beim Start per NAT-PMP
|
||||
den Router (`192.168.2.1:5351`) - auch mit `-nonatmap`, das schaltet nur das
|
||||
Port-Mapping ab, nicht die Erkennung. So früh ignoriert FreeSWITCH SIGPIPE noch
|
||||
nicht -> Prozess tot ~0,5 s nach dem Backgrounding.
|
||||
|
||||
Belegt mit einer zweiten FreeSWITCH-Instanz (eigene Ports, keine Gateways) über
|
||||
einen temporären LaunchAgent: `-nc -rp -nonatmap` stirbt reproduzierbar,
|
||||
`-nf` endet mit exit 141 (SIGPIPE), mit `-nonat` läuft sie. Unified Log im
|
||||
Todesmoment: `UserEventAgent: Got local network blocked notification`. Ein
|
||||
Python-UDP-Test unter launchd: Router -> Errno 32, 9.9.9.9 -> ok.
|
||||
|
||||
**Fix:** `-nonat` im FreeSWITCH-Aufruf in `starten.sh`. Kein Verlust - der
|
||||
Router hat NAT-PMP nie beantwortet (`nat_map status`: UNKNOWN), die externe IP
|
||||
kommt per STUN.
|
||||
|
||||
**Grenze, die bleibt:** Alles, was der Watchdog startet, erreicht **keine
|
||||
LAN-Geräte**. Heute unkritisch (Trunks übers Internet, Sockets auf 127.0.0.1,
|
||||
keine lokalen Telefone registriert). Kommen lokale SIP-Telefone o. Ä. dazu,
|
||||
bricht das unter launchd wieder - dann nicht lange suchen.
|
||||
|
||||
**Noch nicht unter echten Bedingungen verifiziert:** `starten.sh` selbst hat
|
||||
FreeSWITCH mit `-nonat` noch nicht per Watchdog gestartet (solange es läuft,
|
||||
überspringt das Skript es). Test: FreeSWITCH stoppen, dann
|
||||
`launchctl kickstart gui/$(id -u)/net.dillenberg.fonagent` - ~1 min Ausfall.
|
||||
|
||||
### 2. Pipecat-Bootstrap brach mit ModuleNotFoundError ab (Commit `907f511`)
|
||||
|
||||
`starten.sh` startete `dialog.pipecat_bootstrap` mit System-`python3`, dem
|
||||
`gevent`/`greenswitch` fehlen. Jetzt `.venv/bin/python`. Die übrigen
|
||||
`pipe.*`-Dienste laufen weiterhin mit System-`python3` (dort vollständig).
|
||||
|
||||
### 3. Störungswache lief genau beim Ausfall nicht (Commit `6db89af`)
|
||||
|
||||
`pipe.monitor` wurde erst nach Trunk UP gestartet - kam FreeSWITCH nicht hoch,
|
||||
gab es auch keine Wache und keinen Alarm. Jetzt startet sie als Erstes in
|
||||
`starten.sh`, noch vor der Vorabprüfung. `pipe.dienste` meldet "FreeSWITCH
|
||||
läuft nicht" bzw. "Trunk DOWN" selbst.
|
||||
|
||||
**Offen:** Der Alarm ist nur eine lokale macOS-Benachrichtigung (`pipe/alarm.py`,
|
||||
osascript) auf dem Mac mini - wenn dort niemand vor dem Bildschirm sitzt, sieht
|
||||
ihn keiner. Für eine Notdienst-Leitung bräuchte es einen Push-Kanal nach außen.
|
||||
|
||||
## 2026-09-13 (Tag) — Zwei Bugs gefunden und behoben
|
||||
|
||||
### 1. Durchwahl 9 landete bei der Praxis-Zentrale statt bei Astra
|
||||
|
||||
**Symptom:** externer Anruf auf +49 2103 78916179 (Durchwahl 9, "rufagent" im
|
||||
Fonial-Portal, Status Online) klingelte durch, aber es meldete sich die
|
||||
Praxis-Ansage (`00_praxis_ab.xml`) statt Astra.
|
||||
|
||||
**Untersuchung:** `${sip_h_X-ORIGINAL-DDI-URI}` kam korrekt mit
|
||||
`sip:+49210378916179@ipfonie.de` an — die DDI-Erkennung selbst war NICHT das
|
||||
Problem (anders als zunächst vermutet). Es gab schlicht **keine Dialplan-Regel,
|
||||
die auf diese DDI reagiert** — nur `00_dw3_warten.xml` (reagiert exklusiv auf
|
||||
Durchwahl 3) existierte, alles andere fällt durch zu `00_praxis_ab.xml`
|
||||
(`^.*$`, kein `continue="true"`, matcht daher wortwörtlich jede Nummer).
|
||||
|
||||
**Erster Fix-Versuch schlug fehl, zweiter Grund gefunden:** eine neue Regel
|
||||
in einer Datei namens `00_rufagent_astra.xml` wurde nach `reloadxml` immer
|
||||
noch nicht erreicht — der Dialplan-Trace (`fs_cli -x "originate ... &echo()"`-
|
||||
Anrufe mit `call_debug=true` oder `[INFO]`-Log direkt mitlesen) zeigte, dass
|
||||
die Regel im geparsten Dialplan gar nicht auftauchte. Grund: `public.xml`
|
||||
bindet den ganzen Ordner per `<X-PRE-PROCESS cmd="include" data="public/*.xml"/>`
|
||||
**alphabetisch** ein, und `00_praxis_ab.xml`s Catch-all kommt alphabetisch
|
||||
VOR `00_rufagent_astra.xml` (`p` < `r`) und beendet die Dialplan-Auswertung,
|
||||
bevor die eigene Regel überhaupt geladen wird.
|
||||
|
||||
**Fix:** Datei umbenannt zu `00_dw9_rufagent.xml` (`d` < `p`, sortiert direkt
|
||||
hinter `00_dw3_warten.xml` und vor `00_praxis_ab.xml`). Als Vorlage ins Repo
|
||||
übernommen: `telefon/freeswitch/dw9_rufagent.xml.tpl` + `DW9_DDI` in `.env`
|
||||
+ neuer Block in `telefon/freeswitch/einrichten.sh` (analog zum
|
||||
DW3-Block) — die Live-Config kommt jetzt wieder komplett aus dem Repo,
|
||||
reproduzierbar per `einrichten.sh`.
|
||||
|
||||
**Lehre für weitere Durchwahlen:** Jede neue `dialplan/public/*.xml`-Regel,
|
||||
die vor `00_praxis_ab.xml` greifen soll, MUSS einen Dateinamen bekommen, der
|
||||
alphabetisch vor `p` einsortiert (Konvention: `00_dwN_<name>.xml`, `N` als
|
||||
Ziffer direkt nach `00_dw`). Sonst wird sie stillschweigend nie erreicht -
|
||||
kein Fehler, kein Log-Eintrag, sie taucht im Dialplan-Trace einfach nicht auf.
|
||||
|
||||
### 2. Telephone.app kann eine DDI-Registrierung stehlen
|
||||
|
||||
Telephone.app (macOS-Softphone) lief auf minim4-1 mit den SIP-Zugangsdaten
|
||||
einer der Fonial-Durchwahlen für manuelles SIP-Debugging (siehe
|
||||
`/tmp/telephone_call.pcap`-Mitschnitt dort). Läuft es gleichzeitig mit
|
||||
FreeSWITCHs eigener Registrierung derselben Durchwahl, ist nicht
|
||||
deterministisch, wer den Anruf bekommt - siehe auch die Warnung in
|
||||
`telefon/starten.sh` ("Telephone.app LAEUFT - beenden, sonst
|
||||
Registrierungskonflikt"). Vor jedem externen Testanruf prüfen:
|
||||
`pgrep -xl Telephone` auf der Maschine, auf der es installiert ist (hier:
|
||||
minim4-1, nicht minim4-2!), und bei Bedarf beenden.
|
||||
|
||||
## GELÖST 2026-09-13: keine fertige Antwort am Telefon
|
||||
|
||||
Realer externer Testanruf (2026-09-13, ~12:14 Uhr) mit echtem Sprecher:
|
||||
|
||||
- Audio kommt sauber an (Peak ~3000, kein Stille-Artefakt wie bei
|
||||
Loopback/Selbstanruf-Tests).
|
||||
- STT transkribiert korrekt: "kannst du mich hören?", danach "Hallo."
|
||||
- Astra spielt beide Male nur den Zwischenbescheid **"Einen Moment bitte."**
|
||||
- Danach läuft die eigentliche LLM-Antwort (`granite4.2:8b` über Ollama,
|
||||
`NativeOllamaService`) im Astra-Log als `'phase': 'running'` weiter,
|
||||
wird aber nie fertig:
|
||||
- 1. Versuch: nach 4814 ms durch neue erkannte Nutzer-Sprache
|
||||
(`VADUserTurnStartStrategy`) abgebrochen (`'phase': 'cancelled'`) -
|
||||
der Anrufer hat vermutlich aus Ungeduld nochmal "Hallo" gesagt.
|
||||
- 2. Versuch: nach 6002 ms abgebrochen, weil der Anrufer aufgelegt hat
|
||||
(`Anruf beendet` direkt danach im Log, keine erneute Nutzer-Sprache).
|
||||
- In beiden Fällen: **keine einzige echte Antwort hat es bis zur TTS
|
||||
geschafft**, nur der Zwischenbescheid.
|
||||
|
||||
**Hypothese:** Kein Barge-in/Echo-Problem (anders als in der vorherigen
|
||||
Handoff-Version vermutet) - hier ist die reale Sprache eindeutig neu, kein
|
||||
Echo-Artefakt. Verdacht: `granite4.2:8b` per Ollama ist für die
|
||||
Telefon-Pipeline schlicht zu langsam (>5s ohne fertige Antwort), der
|
||||
Anrufer gibt vorher auf. Zu prüfen morgen:
|
||||
- Ollama-Antwortzeit isoliert messen (gleicher Prompt, gleiches Modell,
|
||||
ohne Telefon-Pipeline drumherum) - liegt es am Modell/Backend oder an
|
||||
Rechenlast durch parallel laufende STT/TTS-Modelle auf demselben Mac?
|
||||
- Ob `ASTRA_OLLAMA_URL`/`ASTRA_OLLAMA_BACKENDS` für die Telefonie-Pipeline
|
||||
auf ein schnelleres/entlastetes Backend zeigen sollte (siehe README
|
||||
"Ollama-Backend wählen").
|
||||
- Ob ein kürzerer/schnellerer Zwischenbescheid-Rhythmus (z. B. alle 2-3s
|
||||
ein weiteres "Moment noch") das gefühlte Hängen entschärfen würde, während
|
||||
am eigentlichen Latenzproblem gearbeitet wird.
|
||||
|
||||
**Root Cause bestätigt:** `granite4.2:8b` war zwischen dem Server-Start
|
||||
(01:50 Uhr) und dem Testanruf (12:14 Uhr) aus Ollama verdrängt worden, trotz
|
||||
`keep_alive: -1` in `astra/core.py::build_request` - `ollama ps` zeigte
|
||||
davor `"models": []`. Direkt gemessen: kalt `load_duration` ~12,2s, warm
|
||||
Gesamtdauer <0,9s. Der Anrufer wurde ungeduldig/legte auf, lange bevor die
|
||||
eigentliche Generierung (nach dem Laden nur ~1s) fertig war.
|
||||
|
||||
**Fix:** `astra/server.py::telefon_inbound` feuert jetzt beim
|
||||
Verbindungsaufbau parallel zur Begrüßung einen Wegwerf-Generate-Aufruf an
|
||||
Ollama (`_warm_ollama`, fire-and-forget, Fehler werden ignoriert) - der
|
||||
Kaltstart-Tax landet dadurch während die Begrüßung läuft, nicht während der
|
||||
ersten echten Antwort. Verifiziert mit echtem externen Testanruf: LLM-Zeit
|
||||
1355 ms, vollständige Antwort kam durch, keine Abbrüche mehr. Commit
|
||||
`a66629a` in `martin-voice-interface`.
|
||||
|
||||
**Falls das Problem wiederkehrt** (z. B. weil ein anderer Ollama-Verbraucher
|
||||
zwischen Anrufen ein anderes Modell lädt und `granite4.2:8b` trotzdem wieder
|
||||
verdrängt wird): `curl http://127.0.0.1:11434/api/ps` vor einem Testanruf
|
||||
prüfen, ob das Modell geladen ist.
|
||||
|
||||
## Weiterhin offen (aus der vorherigen Handoff-Fassung, unverändert)
|
||||
|
||||
### Sitzungs-Hänger-Bug (Astra `busy: true` bleibt hängen)
|
||||
|
||||
Noch nicht erneut beobachtet/verifiziert heute, aber nicht aktiv behoben -
|
||||
weiter im Auge behalten: `sessions`-Gate in `astra/server.py` könnte bei
|
||||
einem nicht sauber geschlossenen WebSocket (z. B. hartes `hupall`) hängen
|
||||
bleiben, siehe `run_telephony_call`/`_run_pipeline`s Cleanup-Pfad.
|
||||
|
||||
## Werkzeuge/Referenzen
|
||||
|
||||
- **Test-Nummer** (externe echte Rufnummer, Astra-Testleitung):
|
||||
**+49 2103 78916179** (Durchwahl 9 / "rufagent" im Fonial-Portal). Vor dem
|
||||
Test: Telephone.app auf minim4-1 beenden, falls es läuft
|
||||
(`pgrep -xl Telephone`).
|
||||
- **Loopback (ext. 7501, kein echtes Telefon)**: nur zum Prüfen der
|
||||
Signalisierung/Verkabelung geeignet, liefert IMMER Stille (Peak ~1) - kein
|
||||
Ersatz für einen echten Audiotest.
|
||||
`ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'originate loopback/7501 &park()'"`
|
||||
- **Selbstanruf** (`dialog.anrufen 2`): unzuverlässig, liefert ebenfalls nur
|
||||
Stille (bestätigt 2026-09-13) - nicht mehr nötig jetzt, wo DW9 extern
|
||||
erreichbar ist.
|
||||
- **Nach jedem Testanruf auflegen**:
|
||||
`ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'hupall NORMAL_CLEARING'"`
|
||||
- **FreeSWITCH-Dialplan neu laden** (nach Änderungen in `dialplan/public/`,
|
||||
kein Neustart nötig):
|
||||
`ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'reloadxml'"`
|
||||
- **Astras Live-Log**: `/private/tmp/astra_server.log` auf minim4-1 (NICHT
|
||||
`.runtime/server.log` - das ist ein alter, verwaister Lauf). Bei Zweifel,
|
||||
wohin ein laufender Prozess loggt: `lsof -p <pid> -a -d 0,1,2`.
|
||||
- **Bootstrap-Log**: `fonagent-one/telefon/pipecat_bootstrap.log` auf
|
||||
minim4-2.
|
||||
- **ESL-Port** auf minim4-2 ist **8022**, nicht der Standard 8021.
|
||||
- **Astra neu starten** (siehe vorherige Handoff-Fassung im Git-Log für den
|
||||
vollständigen Befehl mit `ASTRA_TELEFON_SECRET`/`ASTRA_LOG_LEVEL`).
|
||||
|
||||
## Zugangsdaten/Config
|
||||
|
||||
- `.env` auf minim4-2 im Projekt (gitignored) — SIP-Zugänge für `plusnet`,
|
||||
`agentzwei`, `agentzwei_sbc`, `ASTRA_TELEFON_WS_URL`, jetzt auch
|
||||
`DW9_DDI=49210378916179`.
|
||||
- `ASTRA_TELEFON_SECRET` — nur als Laufzeit-Env-Var gesetzt, nicht in einer
|
||||
Datei auf minim4-1 abgelegt. Muss auf beiden Seiten übereinstimmen.
|
||||
|
||||
## GELÖST 2026-09-13 (Folgefehler): Begrüßung komplett stumm nach dem Cap-Fix
|
||||
|
||||
Der Cap-Fix oben (Puffer auf 12s angehoben) legte einen zweiten, tieferen Bug
|
||||
frei: `TTSStoppedFrame` erreicht `serialize()` in diesem Transport **nie** -
|
||||
per Frame-Type-Tracing bestätigt, `FastAPIWebsocketOutputTransport` reicht an
|
||||
den Serializer nur `OutputAudioRawFrame` und (bei Barge-in) `InterruptionFrame`
|
||||
durch, `TTSStoppedFrame` wird intern fürs "Bot hat aufgehört zu sprechen"
|
||||
verbraucht und nie weitergereicht. Der `if isinstance(frame, TTSStoppedFrame)`-
|
||||
Zweig war also von Anfang an toter Code - geflusht wurde bisher ausschließlich
|
||||
über die (zu klein bemessene) Cap. Mit der korrigierten, viel höheren Cap
|
||||
erreichte eine normal lange Antwort (3-4s) diese nie mehr - und ohne den
|
||||
(toten) TTSStoppedFrame-Pfad flusste dann gar nichts mehr: Astra loggte
|
||||
Begrüßung + Transkript ganz normal, aber bei FreeSWITCH kam kein einziges
|
||||
Playback-Event an.
|
||||
|
||||
**Fix:** Flush jetzt über ein Inaktivitäts-Timeout (300ms ohne neuen
|
||||
Audio-Frame) statt über `TTSStoppedFrame`. Da der Idle-Flush aus einem
|
||||
Hintergrund-Task feuert (nicht aus `serialize()`s Rückgabewert), braucht er
|
||||
einen eigenen Sendeweg - der Serializer nimmt jetzt zusätzlich zu `notify`
|
||||
ein `send`-Callable (`websocket.send_text`). Commit `0a0c8d5` in
|
||||
`martin-voice-interface`.
|
||||
|
||||
Live verifiziert (Loopback-Test): 2,88s gesendet, FreeSWITCH spielt 3,02s
|
||||
vollständig ab ("done playing file") - keine Stille, kein Abschneiden mehr.
|
||||
|
||||
## GELÖST 2026-09-13 (weitere Folgefehler): zu lange Antworten + hackige Wiedergabe
|
||||
|
||||
Nach dem Idle-Flush-Fix zwei weitere Probleme live gemeldet:
|
||||
|
||||
1. **Antworten zu lang** - der Prompt allein ("1-2 kurze Sätze") wurde vom
|
||||
Modell ignoriert, Antworten liefen auf 2-3 Sätze und lang genug, um die
|
||||
12s-Puffergrenze zu reißen. Fix: harte `num_predict`-Grenze
|
||||
(`Settings.telefon_max_tokens`, Standard 40, `ASTRA_TELEFON_MAX_TOKENS`
|
||||
env-einstellbar) statt nur einer Bitte im Prompt. Commit `4e6f266`.
|
||||
2. **Wiedergabe "hackig"** - der 300ms-Idle-Flush (siehe vorheriger Eintrag)
|
||||
feuerte bei Mehrsatz-Antworten fast immer VOR dem nächsten Satz, weil die
|
||||
Pause zwischen zwei TTS-Satz-Chunks (das LLM muss den nächsten Satz erst
|
||||
zu Ende streamen) regelmäßig über 300ms liegt - jeder Satz kam so als
|
||||
eigene, einzeln abgespielte Nachricht statt als eine zusammenhängende
|
||||
Antwort. Fix: `flush_now()` auf dem Serializer, ausgelöst über
|
||||
`run_telephony_call`s `notify()`-Hook genau dann, wenn die Pipeline
|
||||
meldet, dass der Bot wirklich fertig gesprochen hat
|
||||
(`BotStoppedSpeakingFrame` via den `'state': 'listening'`-Callback) - das
|
||||
ist das einzige verlässliche "Antwort fertig"-Signal, da `TTSStoppedFrame`
|
||||
selbst `serialize()` nie erreicht (siehe voriger Eintrag). Der
|
||||
Idle-Timeout ist jetzt nur noch Fallback (4s statt 300ms). Commit
|
||||
`42acca4`.
|
||||
|
||||
Live verifiziert: Begrüßung flusht jetzt ~2ms nach echtem Sprechende (statt
|
||||
vorher 300ms Verzögerung), vollständig, keine Aufteilung.
|
||||
|
||||
**Offen für nächsten Test:** ob Mehrsatz-Antworten jetzt tatsächlich flüssig
|
||||
klingen (nur mit Loopback getestet, das produziert keine echte Sprache für
|
||||
einen mehrteiligen Dialog) - braucht einen echten Anruf mit echtem Gespräch.
|
||||
|
||||
## GELÖST 2026-09-13 (weiterer Folgefehler): Anruf trennt nach ~50s ohne Antwort
|
||||
|
||||
Erster echter Mehrsatz-Dialog lief mehrere Runden gut (kurze Antworten,
|
||||
zusammenhängende Wiedergabe) - dann zweimal hintereinander: VAD hat ausgelöst
|
||||
(`min_volume=0.3` reagiert absichtlich auch auf leise Signale, siehe
|
||||
vorheriger Eintrag), aber Nemotron hat für die ganze Runde nichts
|
||||
transkribiert (`Nemotron result: final, 0 characters`). Ein leeres Transkript
|
||||
erreicht `LLMUserAggregator` nie als echte Nutzer-Nachricht - keine
|
||||
LLM-Anfrage, keine Antwort, keine Rückmeldung. Für den Anrufer wirkt das wie
|
||||
eine tote Leitung; nach zwei solchen Runden hat er aufgelegt.
|
||||
|
||||
**Fix:** `NemotronSTTService` bekommt jetzt optional `no_speech_phrases`
|
||||
(`astra.services.NO_SPEECH_PHRASES`, z. B. "Wie bitte?", "Kannst du das
|
||||
wiederholen?") und spricht bei leerem finalem Transkript eine davon über
|
||||
`TTSSpeakFrame`. Nur für die Telefonie verdrahtet (Browser hat schon einen
|
||||
sichtbaren "listening"-Zustand). Commit `c8946d0`.
|
||||
|
||||
**Noch offen:** ob `min_volume=0.3` grundsätzlich zu locker ist (löst zu oft
|
||||
auf Nicht-Sprache aus) - dieser Fix behandelt nur das Symptom (Anrufer bekommt
|
||||
immer eine Reaktion), nicht die Ursache. Bei wiederholtem Auftreten:
|
||||
`ASTRA_TELEFON_VAD_MIN_VOLUME` schrittweise erhöhen und beobachten, ob echte
|
||||
leise Sprache dann noch erkannt wird.
|
||||
155
README.md
155
README.md
|
|
@ -1,30 +1,31 @@
|
|||
# Logpy:AgentOne
|
||||
|
||||
**v1.0.0** — siehe [CHANGELOG.md](CHANGELOG.md)
|
||||
**v1.1.0** — siehe [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
Praxis-Telefon-Agent — nimmt Anrufe (Sprachnachrichten) einer Hausarztpraxis entgegen, **transkribiert**
|
||||
Anruf-Intake-Agent — nimmt Anrufe (Sprachnachrichten) entgegen, **transkribiert**
|
||||
sie lokal, **kategorisiert** das Anliegen und **legt** das Ergebnis strukturiert
|
||||
ab (lokal und optional in Nextcloud).
|
||||
ab (lokal und optional in Nextcloud). Für welchen Einsatzbereich (Hausarztpraxis,
|
||||
24h-Notdienst, …) ist reine Konfiguration — siehe
|
||||
[Branchen-Profil](#kategorien--branchen-profil). Zwei Beispiele liegen bei:
|
||||
`praxis` (Hausarztpraxis) und `aufzug-notdienst`.
|
||||
|
||||
**Vollständig on-prem** — keine Patientendaten verlassen den Rechner.
|
||||
**Vollständig on-prem** — keine Anrufdaten verlassen den Rechner.
|
||||
|
||||

|
||||
|
||||
> **Hinweis:** Alle Namen, Rufnummern und Anliegen in diesem Repository sind
|
||||
> erfunden — auch im Screenshot und in der Beispielkonfiguration. „Praxis
|
||||
> Musterhausen" ist ein Platzhalter. Echte Zugangsdaten und Anrufdaten liegen
|
||||
> außerhalb des Repositorys (`.env`, `ablage/`, `telefon/`).
|
||||
|
||||
Wer das Projekt auf einem weiteren Rechner einrichtet: [docs/MITGABE.md](docs/MITGABE.md)
|
||||
sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen.
|
||||
|
||||
## Stufen
|
||||
|
||||
1. **Die Pipe** (fertig): Audio → Transkript → Kategorie → Ablage.
|
||||
2. **Telefonie** (steht): FreeSWITCH nimmt über den Plusnet-Trunk an, spielt die
|
||||
Ansage, nimmt auf und legt die Datei im Eingang der Pipe ab.
|
||||
3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅ — offen:
|
||||
Monitoring/Alerting bei Diensteausfall, Verschlüsselung at rest.
|
||||
3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅,
|
||||
Verschlüsselung at rest ✅ (Betreiber-Pflicht: FileVault, siehe
|
||||
[Datenschutz](#datenschutz-dsgvo)), Monitoring/Alerting bei Diensteausfall ✅
|
||||
(`pipe/monitor.py`, siehe unten).
|
||||
|
||||
## Bausteine
|
||||
|
||||
|
|
@ -37,19 +38,62 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen.
|
|||
| `pipe/config.py` | Konfiguration aus `.env` |
|
||||
| `pipe/watch.py` | Beobachtet den Eingang und schiebt Aufnahmen durch die Pipe |
|
||||
| `pipe/server.py` | Leitstand: Live-Ansicht mit Status, Anrufen und Verlauf |
|
||||
| `pipe/dienste.py` | Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung, Nextcloud) — geteilt von Leitstand und Störungswache |
|
||||
| `pipe/monitor.py` | Störungswache: alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt |
|
||||
| `pipe/alarm.py` | Verschickt den lokalen macOS-Alarm (osascript) |
|
||||
| `pipe/protokoll.py` | Ereignisprotokoll (JSONL) für den Leitstand |
|
||||
| `pipe/audit.py` | Unveränderliches Audit-Log (append-only, siehe unten) |
|
||||
| `pipe/loeschen.py` | Löscht erledigte Anrufe nach Ablauf der Aufbewahrungsfrist |
|
||||
| `pipe/bewertung.py` | 👍/👎-Bewertung pro Anruf (Leitstand) |
|
||||
| `pipe/export.py` | Exportiert bewertete Anrufe als JSONL-Trainingsdaten |
|
||||
| `prompts/categorize_de.txt` | Kategorisierungs-Prompt (deutsch, mehrsprachig-tauglich) |
|
||||
| `telefon/` | Ansage, FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte |
|
||||
| `pipe/kategorien.py` | Lädt Kategorien-Schema des aktiven Branchen-Profils (siehe unten) |
|
||||
| `profile/<name>/` | Branchen-Profil: Prompt, Kategorien, Sicherheitsnetz, Ansage |
|
||||
| `telefon/` | FreeSWITCH-Vorlagen, Start- und Einrichtungsskripte |
|
||||
|
||||
## Kategorien
|
||||
## Kategorien & Branchen-Profil
|
||||
|
||||
`termin · rezept · ueberweisung · befund · verwaltung · beschwerden · notfall ·
|
||||
rueckruf · sonstiges`
|
||||
Dringlichkeit: `niedrig · normal · hoch · notfall`
|
||||
Alles, was fachlich vom Einsatzzweck abhängt, steckt in einem **Branchen-Profil**
|
||||
unter `profile/<name>/` — vier Dateien, die zueinander passen müssen:
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `prompt.txt` | System-Prompt für `categorize.py`, inkl. Kategorie-Beschreibungen |
|
||||
| `kategorien.json` | `kategorien` (Enum fürs JSON-Schema), `kategorie_labels` (Anzeige in Leitstand/Deck/Dashboard), `sicherheitsnetz_kategorie` (Eskalationsziel) |
|
||||
| `sicherheitsnetz.txt` | Ein Regex-Muster pro Zeile — deterministische Eskalation, unabhängig vom LLM |
|
||||
| `ansage.txt` | Text für `telefon/ansage_bauen.sh` |
|
||||
|
||||
Welches Profil aktiv ist, bestimmt `PROFIL` in der `.env` (Default `praxis`) —
|
||||
ein Wechsel braucht einen Neustart von `pipe.watch`/`pipe.server`, ist aber sonst
|
||||
nichts weiter als diese eine Variable. Mitgeliefert:
|
||||
|
||||
- **`profile/praxis/`** (Default) — Hausarztpraxis:
|
||||
`termin · rezept · ueberweisung · befund · verwaltung · beschwerden · notfall ·
|
||||
rueckruf · sonstiges`
|
||||
- **`profile/aufzug-notdienst/`** (Beispiel/Vorlage) — 24-Stunden-Aufzug-Notdienst:
|
||||
`eingeschlossen · stoerung · wartung · rueckruf · sonstiges`
|
||||
|
||||
> **Wichtig bei `aufzug-notdienst`:** nur eine Vorlage, um den Mechanismus zu
|
||||
> zeigen — nicht fachlich/rechtlich geprüft für echten Betrieb. Und generell
|
||||
> gilt für jedes Profil mit akuter Lebensgefahr am Telefon (Person im Aufzug
|
||||
> eingeschlossen, medizinischer Notfall, …): Diese Pipe nimmt nur auf und
|
||||
> kategorisiert **nachträglich** — es gibt keine Live-Weiterleitung während
|
||||
> des Anrufs. Das Sicherheitsnetz stuft eine Aufnahme nach der Verarbeitung
|
||||
> als Notfall ein, greift aber nicht ein, während der Anrufer noch in der
|
||||
> Leitung ist. Für Szenarien, in denen eine sofortige Reaktion während des
|
||||
> Anrufs überlebenswichtig ist, reicht eine reine Anrufbeantworter-Architektur
|
||||
> allein nicht — das ist nicht Teil dieses Projekts.
|
||||
|
||||
Ein drittes Profil anlegen: `profile/praxis/` kopieren, alle vier Dateien
|
||||
inhaltlich anpassen, `PROFIL=<name>` setzen. Die Dringlichkeitsstufen
|
||||
(`niedrig · normal · hoch · notfall`) sind **nicht** Teil des Profils — die
|
||||
Eskalationsskala ist einsatzzweck-unabhängig und in Leitstand/Dashboard/Deck
|
||||
fest verdrahtet (Farben, Sortierung, Notfall-Alarm).
|
||||
|
||||
> **Hinweis:** `prompt.txt` beschreibt die Kategorien in Prosa fürs Modell,
|
||||
> `kategorien.json` erzwingt sie als Enum im JSON-Schema — bewusst zwei
|
||||
> Dateien, aber sie müssen inhaltlich zusammenpassen. Ändert man die
|
||||
> Kategorien-Liste, muss der Prompt-Text mitgezogen werden, sonst kann das
|
||||
> Modell eine im Prompt beschriebene Kategorie wählen, die das Schema ablehnt.
|
||||
|
||||
## Einrichtung
|
||||
|
||||
|
|
@ -136,7 +180,7 @@ eingehende Anrufe an, spielt die Ansage und nimmt auf. Die Aufnahme landet unter
|
|||
sie durch die Pipe und räumt sie nach `telefon/verarbeitet/` weg.
|
||||
|
||||
```bash
|
||||
./telefon/ansage_bauen.sh # Ansage aus telefon/ansage.txt erzeugen
|
||||
./telefon/ansage_bauen.sh # Ansage aus profile/$PROFIL/ansage.txt erzeugen
|
||||
./telefon/freeswitch/einrichten.sh # Trunk + Dialplan in FreeSWITCH eintragen
|
||||
./telefon/starten.sh # startet FreeSWITCH, Verarbeitung, Leitstand
|
||||
```
|
||||
|
|
@ -192,7 +236,37 @@ Der SIP-Zugang steht in der `.env` (`SIP_USER`, `SIP_DOMAIN`, `SIP_PASS`). Das
|
|||
Einrichtungsskript trägt das Passwort in die FreeSWITCH-Konfiguration ein und
|
||||
setzt deren Rechte auf 600 — im Git liegen nur Vorlagen mit Platzhaltern.
|
||||
|
||||
Text ändern: `telefon/ansage.txt` bearbeiten, `ansage_bauen.sh` erneut
|
||||
### Durchwahl parallel zu einem Endgerät klingeln lassen
|
||||
|
||||
Manche Provider (z. B. Fonial/Plusnet) klingeln bei einer Durchwahl **parallel**
|
||||
an allen dafür registrierten Zielen — bei uns also gleichzeitig an AgentOne und
|
||||
an einem echten Telefon/Softphone. Ohne Gegenmaßnahme gewinnt AgentOne das
|
||||
Rennen aber immer: `answer()` ist im Dialplan die erste Aktion, es gibt keine
|
||||
Verzögerung davor. Das Telefon klingelt dann faktisch nie, weil der Anruf schon
|
||||
beim Assistenten gelandet ist, bevor am Gerät überhaupt ein Klingelzeichen
|
||||
ankommt.
|
||||
|
||||
`DW3_DDI` in der `.env` schaltet eine Wartezeit vor `answer()` frei
|
||||
(`telefon/freeswitch/dw3_warten.xml.tpl`, deployed als `00_dw3_warten.xml` –
|
||||
lädt alphabetisch vor `00_praxis_ab.xml`): erkennt der Dialplan die passende
|
||||
Durchwahl, wartet er 20 Sekunden, bevor überhaupt abgenommen wird. Nimmt in der
|
||||
Zeit jemand am registrierten Endgerät ab, bricht der Provider den Versuch bei
|
||||
AgentOne per SIP-`CANCEL` ab — der Dialplan kommt gar nicht mehr bis zum
|
||||
`answer()`. Nimmt niemand ab, läuft er nach den 20 Sekunden normal weiter zu
|
||||
Ansage und Aufnahme, identisch zum Standard-Anrufbeantworter.
|
||||
|
||||
Wichtig: die Durchwahl selbst kommt bei diesem Trunk nicht im
|
||||
`destination_number` an (der zeigt bei Plusnet nur eine interne Trunk-Kennung,
|
||||
unabhängig von der gewählten Nummer), sondern im SIP-Header
|
||||
`X-ORIGINAL-DDI-URI` der eingehenden Einladung — nur der ist brauchbar, um DW3
|
||||
von anderen Anrufen zu unterscheiden. `DW3_DDI` erwartet die volle Rufnummer
|
||||
inklusive Durchwahl-Ziffer(n), nur Ziffern, kein `+`.
|
||||
|
||||
Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim
|
||||
Anrufbeantworter wie bisher.
|
||||
|
||||
Text ändern: `profile/$PROFIL/ansage.txt` bearbeiten (siehe
|
||||
[Branchen-Profil](#kategorien--branchen-profil)), `ansage_bauen.sh` erneut
|
||||
ausführen. Für den Praxisbetrieb muss die Ansage den Notruf 112 nennen und auf
|
||||
die Aufzeichnung hinweisen, bevor der Signalton kommt — beides ist bei einer
|
||||
Arztpraxis Pflicht, siehe „Offener Punkt" unter Datenschutz weiter unten.
|
||||
|
|
@ -247,6 +321,29 @@ in `telefon/audit.log` — anders als `verlauf.log` (fürs Leitstand-UI, wird au
|
|||
zusätzlich per macOS `chflags uappnd` gegen nachträgliches Ändern/Kürzen
|
||||
abgesichert (auch der eigene Prozess kommt danach nur noch per Anhängen rein).
|
||||
|
||||
## Störungswache (Monitoring/Alerting)
|
||||
|
||||
Die Dienste-Ampel im Leitstand (Telefon, Verarbeitung, Spracherkennung,
|
||||
Nextcloud) zeigt den Zustand nur an, wenn gerade jemand die Seite offen hat.
|
||||
`pipe/monitor.py` läuft deshalb als **eigener Prozess** (von
|
||||
`telefon/starten.sh` mitgestartet, Log `telefon/monitor.log`), fragt dieselbe
|
||||
Ampel (`pipe/dienste.py`) alle `MONITOR_TAKT_S` Sekunden (Default 60) ab und
|
||||
alarmiert bei einer echten Störung (`schlecht`/`aus` — nicht bei `unklar`,
|
||||
z. B. Nextcloud unkonfiguriert) lokal per macOS-Systembenachrichtigung + Ton
|
||||
(`pipe/alarm.py`, `osascript`). Solange die Störung anhält, wird alle
|
||||
`MONITOR_WIEDERHOLUNG_MIN` Minuten (Default 15) erneut erinnert; bei
|
||||
Wiederherstellung einmalig „wieder ok".
|
||||
|
||||
Bewusst ein eigener, dritter Prozess statt in `pipe.watch` oder `pipe.server`
|
||||
eingebaut: fällt einer der beiden aus, kann die Wache das trotzdem noch
|
||||
melden. Wirkt nur lokal auf diesem Mac — für Alarme aufs Handy (z. B. per
|
||||
Push/E-Mail) `pipe/alarm.py` erweitern.
|
||||
|
||||
```bash
|
||||
python3 -m pipe.monitor # dauerhaft beobachten (normalerweise per starten.sh)
|
||||
python3 -m pipe.monitor --einmal # einmal prüfen, für Tests
|
||||
```
|
||||
|
||||
## Bewertung & Trainingsdaten-Export
|
||||
|
||||
Jede Karte im Leitstand hat 👍/👎 — war die Kategorisierung richtig? Bei 👎
|
||||
|
|
@ -271,7 +368,21 @@ Gesundheitsdaten — Pflichten für den Produktivbetrieb (Stufe 2):
|
|||
- Löschfristen — siehe oben (`LOESCHFRIST_TAGE`).
|
||||
- Verarbeitung lokal (Whisper + Qwen) — kein Cloud-Dienst im Datenpfad.
|
||||
|
||||
> **Offener Punkt:** Die aktuelle `telefon/ansage.txt` erfüllt die ersten
|
||||
> beiden Punkte noch nicht (kein Notfall-Hinweis, keine Aufnahme-Einwilligung)
|
||||
> — Text anpassen und `ansage_bauen.sh` neu laufen lassen, bevor die Anlage im
|
||||
> echten Praxisbetrieb ans Netz geht.
|
||||
> **Offener Punkt:** Die aktuelle `profile/praxis/ansage.txt` erfüllt die
|
||||
> ersten beiden Punkte noch nicht (kein Notfall-Hinweis, keine
|
||||
> Aufnahme-Einwilligung) — Text anpassen und `ansage_bauen.sh` neu laufen
|
||||
> lassen, bevor die Anlage im echten Praxisbetrieb ans Netz geht.
|
||||
|
||||
> **Verschlüsselung at rest — Pflicht des Betreibers:** Die Pipe legt
|
||||
> Aufnahmen, Transkripte und Metadaten (`ablage/`, `telefon/`, `training/`)
|
||||
> unverschlüsselt auf der Platte ab und verlässt sich auf
|
||||
> Festplattenverschlüsselung durch das Betriebssystem. **FileVault muss auf
|
||||
> jedem Rechner aktiviert sein, der diese Pipe betreibt**
|
||||
> (Systemeinstellungen → Datenschutz & Sicherheit → FileVault, oder
|
||||
> `sudo fdesetup enable`) — den Recovery-Key sicher verwahren (Passwort-
|
||||
> Tresor), da er das einzige Mittel bei vergessenem Login-Passwort ist.
|
||||
> Ist FileVault auf einem Zielsystem nicht aktivierbar oder nicht zulässig
|
||||
> (z. B. Mehrbenutzer-Rechner, andere Auflagen), reicht das nicht aus — dann
|
||||
> braucht die App eine eigene Verschlüsselung der Ablage, die es aktuell
|
||||
> **nicht** gibt. Das Aktivieren/Prüfen von FileVault ist Aufgabe des
|
||||
> Betreibers vor Ort, nicht Teil dieses Repos.
|
||||
|
|
|
|||
0
dialog/__init__.py
Normal file
0
dialog/__init__.py
Normal file
68
dialog/anrufen.py
Normal file
68
dialog/anrufen.py
Normal 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
187
dialog/pipecat_bootstrap.py
Normal 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()
|
||||
129
docs/MITGABE.md
129
docs/MITGABE.md
|
|
@ -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
BIN
docs/logpy-agentone-1-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
27
pipe/alarm.py
Normal file
27
pipe/alarm.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Lokaler macOS-Alarm (Systembenachrichtigung + Ton) für pipe.monitor.
|
||||
|
||||
Wirft nie - eine fehlgeschlagene Benachrichtigung darf die Wache nicht zum
|
||||
Absturz bringen. Nutzt osascript (stdlib-Unterprozess, kein Framework, keine
|
||||
Abhängigkeit von einem laufenden Nextcloud/Cloud-Dienst).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
def _quote(text: str) -> str:
|
||||
"""AppleScript-String-Literal - Anführungszeichen/Backslashes escapen."""
|
||||
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def sende(titel: str, text: str) -> None:
|
||||
skript = (
|
||||
f"display notification {_quote(text)} with title {_quote(titel)} "
|
||||
f"sound name {_quote(config.MONITOR_TON)}"
|
||||
)
|
||||
try:
|
||||
subprocess.run(["osascript", "-e", skript], capture_output=True, timeout=10)
|
||||
except Exception as fehler:
|
||||
print(f" Alarm konnte nicht zugestellt werden: {fehler}")
|
||||
|
|
@ -15,40 +15,33 @@ import urllib.error
|
|||
import urllib.request
|
||||
from functools import lru_cache
|
||||
|
||||
from . import config, modellwahl
|
||||
from . import config, kategorien, modellwahl
|
||||
|
||||
KATEGORIEN = [
|
||||
"termin", "rezept", "ueberweisung", "befund",
|
||||
"verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges",
|
||||
]
|
||||
DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"]
|
||||
KATEGORIEN = kategorien.KATEGORIEN
|
||||
DRINGLICHKEITEN = kategorien.DRINGLICHKEITEN
|
||||
|
||||
# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: dieselben Symptome,
|
||||
# die der Prompt selbst als Notfall-Beispiele nennt (Atemnot, Brustschmerz,
|
||||
# Bewusstlosigkeit, starke Blutung). Grund: getestet mit qwen3:8b, 3/3
|
||||
# identische Laeufe stuften "starke Brustschmerzen ... kriege kaum Luft" nur
|
||||
# als "hoch" statt "notfall" ein - obwohl das Modell selbst "Brustschmerzen"
|
||||
# und "Atemnot" als stichworte erkannte. Bei Notfall-Erkennung darf man sich
|
||||
# nicht allein auf ein LLM verlassen. Eskaliert nur nach oben, nie nach unten
|
||||
# - ein Fehlalarm kostet einen Blick, ein uebersehener Notfall kann einen
|
||||
# Patienten kosten (siehe auch die "im Zweifel hoeher"-Regel im Prompt).
|
||||
_NOTFALL_MUSTER = [re.compile(p, re.IGNORECASE) for p in (
|
||||
r"atemnot",
|
||||
r"(kriege|bekomme|krieg)\w*\s+(kaum|keine|schwer)\s+luft",
|
||||
r"kann\s+(kaum|nicht)\s+(mehr\s+)?atmen",
|
||||
r"brust\w*schmerz",
|
||||
r"schmerzen?\s+in\s+der\s+brust",
|
||||
r"bewusstlos",
|
||||
r"ohnm[aä]chtig",
|
||||
r"nicht\s+ansprechbar",
|
||||
r"blutet\s+(stark|heftig|sehr)",
|
||||
r"starke?\s+blutung",
|
||||
)]
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _sicherheitsnetz_muster() -> list[re.Pattern]:
|
||||
"""Lädt die Eskalations-Regex-Muster aus profile/<PROFIL>/sicherheitsnetz.txt.
|
||||
|
||||
Ein Muster pro Zeile, Kommentare ("#") und Leerzeilen werden übersprungen.
|
||||
Deterministisch, unabhängig vom LLM - siehe Rationale in der Profil-Datei
|
||||
selbst. Eskaliert nur nach oben, nie nach unten.
|
||||
"""
|
||||
if not config.SICHERHEITSNETZ_DATEI.is_file():
|
||||
return []
|
||||
return [
|
||||
re.compile(zeile, re.IGNORECASE)
|
||||
for zeile in (z.strip() for z in config.SICHERHEITSNETZ_DATEI.read_text(encoding="utf-8").splitlines())
|
||||
if zeile and not zeile.startswith("#")
|
||||
]
|
||||
|
||||
|
||||
def _notfall_sicherheitsnetz(transkript: str, d: dict) -> dict:
|
||||
if d.get("dringlichkeit") != "notfall" and any(m.search(transkript) for m in _NOTFALL_MUSTER):
|
||||
d["kategorie"] = "notfall"
|
||||
ziel = kategorien.SICHERHEITSNETZ_KATEGORIE
|
||||
if d.get("kategorie") != ziel and any(m.search(transkript) for m in _sicherheitsnetz_muster()):
|
||||
d["kategorie"] = ziel
|
||||
d["dringlichkeit"] = "notfall"
|
||||
return d
|
||||
|
||||
|
|
@ -244,11 +237,11 @@ def _bereinige(d: dict) -> dict:
|
|||
d["kategorie"] = "sonstiges"
|
||||
if d.get("dringlichkeit") not in DRINGLICHKEITEN:
|
||||
d["dringlichkeit"] = "normal"
|
||||
# Das Modell vergibt gelegentlich die Kategorie "notfall" und stuft die
|
||||
# Dringlichkeit trotzdem nur auf "hoch" ein. Das ist widersprüchlich und
|
||||
# führt zu einer harmloseren Farbe auf dem Board, als der Anruf verdient.
|
||||
# In der Praxis eskaliert man im Zweifel, statt abzuschwächen.
|
||||
if d["kategorie"] == "notfall":
|
||||
# Das Modell vergibt gelegentlich die Sicherheitsnetz-Kategorie (z. B.
|
||||
# "notfall") und stuft die Dringlichkeit trotzdem nur auf "hoch" ein. Das
|
||||
# ist widersprüchlich und führt zu einer harmloseren Farbe auf dem Board,
|
||||
# als der Anruf verdient. Im Zweifel eskaliert man, statt abzuschwächen.
|
||||
if d["kategorie"] == kategorien.SICHERHEITSNETZ_KATEGORIE:
|
||||
d["dringlichkeit"] = "notfall"
|
||||
d.setdefault("anrufer_name", None)
|
||||
d.setdefault("rueckrufnummer", None)
|
||||
|
|
|
|||
|
|
@ -83,9 +83,21 @@ OPENROUTER_KEY = os.environ.get("OPENROUTER_KEY", "")
|
|||
# hinterlegten (siehe pipe/modellwahl.py) - leer lassen wenn nicht gebraucht.
|
||||
OPENROUTER_MODELL = os.environ.get("OPENROUTER_MODELL", "")
|
||||
|
||||
PROMPT_DATEI = WURZEL / "prompts" / os.environ.get(
|
||||
"PROMPT_DATEI", "categorize_de.txt"
|
||||
)
|
||||
# --- Branchen-Profil -----------------------------------------------------
|
||||
# Buendelt alles, was fachlich vom Einsatzzweck abhaengt (Kategorien, Prompt,
|
||||
# Notfall-Sicherheitsnetz, Ansage) an einer Stelle - siehe profile/<name>/.
|
||||
# So bleiben diese vier Teile beim Wechsel des Einsatzzwecks (z.B. Praxis vs.
|
||||
# 24h-Aufzug-Notdienst) automatisch zueinander konsistent, statt dass man eine
|
||||
# Datei beim Umstellen vergisst. Neues Profil anlegen: profile/<name>/ Ordner
|
||||
# mit prompt.txt, kategorien.json, sicherheitsnetz.txt, ansage.txt anlegen
|
||||
# (profile/praxis/ als Vorlage nehmen) und hier PROFIL=<name> setzen.
|
||||
PROFIL = os.environ.get("PROFIL", "praxis").strip() or "praxis"
|
||||
PROFIL_ORDNER = WURZEL / "profile" / PROFIL
|
||||
|
||||
PROMPT_DATEI = PROFIL_ORDNER / "prompt.txt"
|
||||
KATEGORIEN_DATEI = PROFIL_ORDNER / "kategorien.json"
|
||||
SICHERHEITSNETZ_DATEI = PROFIL_ORDNER / "sicherheitsnetz.txt"
|
||||
ANSAGE_DATEI = PROFIL_ORDNER / "ansage.txt"
|
||||
|
||||
# --- Ablage ------------------------------------------------------------------
|
||||
ABLAGE_LOKAL = Path(os.environ.get("ABLAGE_LOKAL", str(WURZEL / "ablage")))
|
||||
|
|
@ -105,6 +117,28 @@ LEITSTAND_PORT = int(os.environ.get("LEITSTAND_PORT", "8088"))
|
|||
# auf den Seiten stehen Transkripte und Aufnahmen von Patienten.
|
||||
LEITSTAND_USER = os.environ.get("LEITSTAND_USER", "praxis")
|
||||
LEITSTAND_PASS = os.environ.get("LEITSTAND_PASS", "")
|
||||
# Weitere Konten zusaetzlich zum obigen (z.B. ein eigenes Konto fuer den
|
||||
# Notdienst neben dem Praxis-Konto) - kommagetrennt "nutzer:passwort",
|
||||
# z.B. "notdienst:Berg,spaeter:nochwas".
|
||||
LEITSTAND_ZUGAENGE = os.environ.get("LEITSTAND_ZUGAENGE", "")
|
||||
|
||||
|
||||
def leitstand_konten() -> dict[str, str]:
|
||||
"""Alle gueltigen Leitstand-Logins als {nutzername: passwort}."""
|
||||
konten = {}
|
||||
if LEITSTAND_PASS:
|
||||
konten[LEITSTAND_USER] = LEITSTAND_PASS
|
||||
for eintrag in LEITSTAND_ZUGAENGE.split(","):
|
||||
nutzer, _, passwort = eintrag.strip().partition(":")
|
||||
if nutzer and passwort:
|
||||
konten[nutzer] = passwort
|
||||
return konten
|
||||
|
||||
# Geteiltes Geheimnis mit dilles-agent.php ("agent-one test"-Kommando auf
|
||||
# dillenberg.net) fuer signierte, 1h gueltige Testzugangslinks - siehe
|
||||
# pipe.testzugang. Leer lassen deaktiviert das Feature (kein Fallback-Wert,
|
||||
# ein leeres Secret darf niemals gueltige Signaturen erzeugen).
|
||||
TESTZUGANG_SECRET = os.environ.get("TESTZUGANG_SECRET", "")
|
||||
|
||||
|
||||
def leitstand_oeffentlich() -> bool:
|
||||
|
|
@ -162,6 +196,19 @@ def nextcloud_aktiv() -> bool:
|
|||
return bool(NEXTCLOUD_URL and NEXTCLOUD_USER and NEXTCLOUD_PASS)
|
||||
|
||||
|
||||
# --- Störungswache (pipe.monitor) --------------------------------------------
|
||||
# Prueft die Dienste-Ampel (pipe.dienste: Telefon, Verarbeitung, Spracherkennung,
|
||||
# Nextcloud) periodisch und alarmiert lokal per macOS-Benachrichtigung + Ton,
|
||||
# wenn niemand auf den Leitstand schaut. Eigener Prozess (siehe
|
||||
# telefon/starten.sh) - faellt pipe.watch oder pipe.server aus, meldet die
|
||||
# Wache das trotzdem noch.
|
||||
MONITOR_TAKT_S = int(os.environ.get("MONITOR_TAKT_S", "60") or "60")
|
||||
# Erinnerungsabstand, waehrend eine Stoerung anhaelt - sonst nur ein einziger
|
||||
# Alarm beim Ausfall, der leicht untergeht.
|
||||
MONITOR_WIEDERHOLUNG_MIN = int(os.environ.get("MONITOR_WIEDERHOLUNG_MIN", "15") or "15")
|
||||
# macOS-Systemton (siehe /System/Library/Sounds), der bei jedem Alarm abgespielt wird.
|
||||
MONITOR_TON = os.environ.get("MONITOR_TON", "Sosumi").strip() or "Sosumi"
|
||||
|
||||
# --- Aufbewahrungsfrist (pipe.loeschen) --------------------------------------
|
||||
# Nach so vielen Tagen wird ein Anruf-Ordner (Aufnahme + Transkript + Meta)
|
||||
# geloescht - lokal und, falls hochgeladen, auch bei Nextcloud. 0 = aus (nichts
|
||||
|
|
|
|||
|
|
@ -11,15 +11,11 @@ import json
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import config
|
||||
from . import config, kategorien
|
||||
|
||||
RANG = {"notfall": 0, "hoch": 1, "normal": 2, "niedrig": 3}
|
||||
FARBE = {"notfall": "#E9322D", "hoch": "#E0A339", "normal": "#C9A227", "niedrig": "#31CC7C"}
|
||||
KAT_LABEL = {
|
||||
"termin": "Termin", "rezept": "Rezept", "ueberweisung": "Überweisung",
|
||||
"befund": "Befund", "verwaltung": "Verwaltung", "beschwerden": "Beschwerden", "notfall": "Notfall",
|
||||
"rueckruf": "Rückruf", "sonstiges": "Sonstiges",
|
||||
}
|
||||
RANG = kategorien.DRINGLICHKEIT_RANG
|
||||
FARBE = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()}
|
||||
KAT_LABEL = kategorien.KATEGORIE_LABEL
|
||||
|
||||
|
||||
def _anrufe() -> list[dict]:
|
||||
|
|
@ -65,7 +61,7 @@ def baue(ausgabe: Path) -> Path:
|
|||
karten = "\n".join(_karte(d) for d in anrufe)
|
||||
doc = f"""<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Anruf-Übersicht — Praxis Musterhausen</title>
|
||||
<title>Anruf-Übersicht · {html.escape(kategorien.PROFIL_NAME)}</title>
|
||||
<style>
|
||||
:root{{--bg:#f6f7f9;--fg:#1a2230;--karte:#fff;--rand:#e4e8ee;--muted:#6b7688}}
|
||||
@media(prefers-color-scheme:dark){{:root{{--bg:#12151b;--fg:#e7ecf3;--karte:#1b2029;--rand:#2a313d;--muted:#93a0b4}}}}
|
||||
|
|
@ -86,7 +82,7 @@ padding:1px 7px;font-size:12px;color:var(--muted);margin:2px 4px 2px 0}}
|
|||
details{{margin-top:6px}}summary{{cursor:pointer;color:var(--muted);font-size:13px}}
|
||||
.tr{{color:var(--muted);font-size:13px;margin:6px 0 0}}
|
||||
</style></head><body><div class="wrap">
|
||||
<h1>Anruf-Übersicht</h1>
|
||||
<h1>Anruf-Übersicht · {html.escape(kategorien.PROFIL_NAME)}</h1>
|
||||
<p class="sub">{len(anrufe)} Anrufe · {zusammenfassung or '—'} · lokal erzeugt aus der Ablage</p>
|
||||
{karten}
|
||||
</div></body></html>"""
|
||||
|
|
|
|||
17
pipe/deck.py
17
pipe/deck.py
|
|
@ -18,24 +18,15 @@ import urllib.error
|
|||
import urllib.request
|
||||
from functools import lru_cache
|
||||
|
||||
from . import config
|
||||
from . import config, kategorien
|
||||
|
||||
_API = "/index.php/apps/deck/api/v1.1"
|
||||
|
||||
# Farben (6-stelliges Hex ohne #) je Dringlichkeit.
|
||||
DRINGLICHKEIT_FARBE = {
|
||||
"niedrig": "31CC7C", # grün
|
||||
"normal": "F1DB50", # gelb
|
||||
"hoch": "E0A339", # orange
|
||||
"notfall": "E9322D", # rot
|
||||
}
|
||||
# Farben je Dringlichkeit - siehe pipe/kategorien.py.
|
||||
DRINGLICHKEIT_FARBE = kategorien.DRINGLICHKEIT_FARBE
|
||||
# Lesbare Namen der Kategorien für den Kartentitel - der Stapel sagt jetzt,
|
||||
# wie weit die Bearbeitung ist, nicht mehr worum es geht.
|
||||
KATEGORIE_LABEL = {
|
||||
"termin": "Termin", "rezept": "Rezept", "ueberweisung": "Überweisung",
|
||||
"befund": "Befund", "verwaltung": "Verwaltung", "beschwerden": "Beschwerden", "notfall": "Notfall",
|
||||
"rueckruf": "Rückruf", "sonstiges": "Sonstiges",
|
||||
}
|
||||
KATEGORIE_LABEL = kategorien.KATEGORIE_LABEL
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
|
|
|||
96
pipe/dienste.py
Normal file
96
pipe/dienste.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung,
|
||||
Nextcloud) — geteilt zwischen Leitstand (pipe.server) und Störungswache
|
||||
(pipe.monitor), damit beide dieselbe Sicht auf "läuft/steht" haben.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
from . import config, store
|
||||
|
||||
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
|
||||
# damit haeufiges Nachfragen den Rechner nicht belastet.
|
||||
_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
CACHE_S = 4.0
|
||||
|
||||
|
||||
def _laeuft(muster: str) -> bool:
|
||||
try:
|
||||
return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _http_ok(url: str, timeout: float = 5.0) -> bool:
|
||||
"""Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud
|
||||
blockt den Standardnamen von urllib und meldete fälschlich 'nicht
|
||||
erreichbar'."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()):
|
||||
return True
|
||||
except urllib.error.HTTPError:
|
||||
return True # antwortet - reicht als Lebenszeichen
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _trunk() -> tuple[str, str]:
|
||||
"""(zustand, text) des SIP-Trunks."""
|
||||
if not _laeuft("[f]reeswitch"):
|
||||
return "aus", "FreeSWITCH läuft nicht"
|
||||
try:
|
||||
roh = subprocess.run(
|
||||
["fs_cli", "-P", config.FS_PORT, "-x", "sofia status gateway plusnet"],
|
||||
capture_output=True, text=True, timeout=8).stdout
|
||||
for zeile in roh.splitlines():
|
||||
if zeile.startswith("Status"):
|
||||
wert = zeile.split()[-1]
|
||||
return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}")
|
||||
return "unklar", "Gateway unbekannt"
|
||||
except Exception:
|
||||
return "unklar", "fs_cli nicht erreichbar"
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
jetzt = time.time()
|
||||
gepuffert = _CACHE.get("status")
|
||||
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
|
||||
return gepuffert[1]
|
||||
|
||||
trunk_zustand, trunk_text = _trunk()
|
||||
watcher = _laeuft("[p]ipe.watch")
|
||||
ollama = _http_ok(f"{config.OLLAMA_URL}/api/version")
|
||||
nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php")
|
||||
if config.nextcloud_aktiv() else None)
|
||||
|
||||
offen = 0
|
||||
if config.TELEFON_EINGANG.is_dir():
|
||||
offen = sum(1 for d in config.TELEFON_EINGANG.iterdir()
|
||||
if d.is_file() and not d.name.startswith("."))
|
||||
fehler = 0
|
||||
if config.TELEFON_FEHLER.is_dir():
|
||||
fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file())
|
||||
|
||||
daten = {
|
||||
"dienste": [
|
||||
{"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text},
|
||||
{"name": "Verarbeitung", "zustand": "gut" if watcher else "aus",
|
||||
"text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"},
|
||||
{"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht",
|
||||
"text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"},
|
||||
{"name": "Nextcloud", "zustand": "unklar" if nextcloud is None
|
||||
else ("gut" if nextcloud else "schlecht"),
|
||||
"text": "nicht konfiguriert" if nextcloud is None
|
||||
else ("erreichbar" if nextcloud else "nicht erreichbar")},
|
||||
],
|
||||
"eingang_offen": offen,
|
||||
"fehler": fehler,
|
||||
"stand": datetime.now().strftime("%H:%M:%S"),
|
||||
}
|
||||
_CACHE["status"] = (jetzt, daten)
|
||||
return daten
|
||||
48
pipe/kategorien.py
Normal file
48
pipe/kategorien.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Kategorien-Schema des aktiven Branchen-Profils (profile/<PROFIL>/kategorien.json).
|
||||
|
||||
Gebündelt an einer Stelle, damit categorize.py (JSON-Schema, Eskalationsziel
|
||||
des Sicherheitsnetzes) und die Anzeige (dashboard.py, deck.py: Kategorie-
|
||||
Label) dieselbe Quelle nutzen - ein neues Profil ändert nur die JSON-Datei,
|
||||
kein Python-Code.
|
||||
|
||||
Dringlichkeitsstufen sind bewusst NICHT Teil des Profils: die vierstufige
|
||||
Eskalationsskala (niedrig/normal/hoch/notfall) ist einsatzzweck-unabhängig.
|
||||
Farbe und Sortierrang je Stufe stehen deshalb ebenfalls hier - eine Quelle
|
||||
für dashboard.py, deck.py und den Leitstand (server.py), statt an drei
|
||||
Stellen von Hand synchron zu halten.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
from . import config
|
||||
|
||||
DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"]
|
||||
# Farben (6-stelliges Hex ohne "#" - so will es Nextcloud Deck; dashboard.py
|
||||
# und server.py stellen sich das "#" selbst voran).
|
||||
DRINGLICHKEIT_FARBE = {
|
||||
"niedrig": "8A93A3", # grau - unwichtig
|
||||
"normal": "31CC7C", # grün
|
||||
"hoch": "E0A339", # orange
|
||||
"notfall": "E9322D", # rot
|
||||
}
|
||||
# Sortierrang (0 = dringlichst zuerst) - aus DRINGLICHKEITEN abgeleitet statt
|
||||
# separat gepflegt.
|
||||
DRINGLICHKEIT_RANG = {d: i for i, d in enumerate(reversed(DRINGLICHKEITEN))}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _daten() -> dict:
|
||||
return json.loads(config.KATEGORIEN_DATEI.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
KATEGORIEN = _daten()["kategorien"]
|
||||
KATEGORIE_LABEL = _daten().get("kategorie_labels", {})
|
||||
# Anzeigename des aktiven Profils (Leitstand-Header, Dashboard) - Fallback auf
|
||||
# den rohen Ordnernamen, falls ein Profil das Feld nicht setzt.
|
||||
PROFIL_NAME = _daten().get("profil_name", config.PROFIL)
|
||||
# Kategorie, auf die das deterministische Sicherheitsnetz eskaliert (siehe
|
||||
# categorize._notfall_sicherheitsnetz) - bei "praxis" ist das "notfall", bei
|
||||
# anderen Profilen ggf. ein anderer Name (z. B. "eingeschlossen").
|
||||
SICHERHEITSNETZ_KATEGORIE = _daten().get("sicherheitsnetz_kategorie", "notfall")
|
||||
|
|
@ -5,49 +5,104 @@ Der Watcher (pipe.watch, Langzeit-Prozess) und der Leitstand (pipe.server,
|
|||
eigener Prozess) laufen getrennt - die Auswahl wird deshalb in einer kleinen
|
||||
JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein
|
||||
Caching), damit eine Umschaltung sofort beim naechsten Anruf greift.
|
||||
|
||||
Die lokalen Ollama-Modelle werden live von Ollama abgefragt (GET /api/tags)
|
||||
statt fest im Code zu stehen - jedes per "ollama pull" installierte Modell
|
||||
taucht automatisch im Dropdown auf, ohne Code-Aenderung. Eine feste
|
||||
OpenRouter-Testauswahl bleibt daneben bestehen: die Modell-IDs dort lassen
|
||||
sich nicht lokal verifizieren, ein Tippfehler wuerde sonst erst beim naechsten
|
||||
Anruf sichtbar - fuer freie Eingabe siehe OPENROUTER_MODELL in der .env.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from . import config
|
||||
|
||||
DATEI = config.TELEFON / "modell.json"
|
||||
|
||||
# Feste, kuratierte Auswahl statt freier Eingabe - ein Tippfehler in einer
|
||||
# Modell-ID im Leitstand darf nicht den naechsten Anruf scheitern lassen.
|
||||
# "openrouter"-Eintraege sind zum Testen gedacht (Transkript verlaesst dabei
|
||||
# den Rechner) - nicht fuer den Praxisbetrieb.
|
||||
AUSWAHL = [
|
||||
{"id": "ollama", "label": f"{config.OLLAMA_MODELL} (lokal)",
|
||||
"backend": "ollama", "modell": config.OLLAMA_MODELL},
|
||||
{"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"},
|
||||
{"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"},
|
||||
]
|
||||
# Optionaler vierter Eintrag zum schnellen Ausprobieren eines beliebigen
|
||||
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in
|
||||
# .env setzen.
|
||||
if config.OPENROUTER_MODELL:
|
||||
AUSWAHL.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": config.OPENROUTER_MODELL})
|
||||
_STANDARD = AUSWAHL[0]["id"]
|
||||
_NACH_ID = {e["id"]: e for e in AUSWAHL}
|
||||
# Ollama-Abfrage ist ein Netzwerkaufruf - kurz zwischenspeichern, damit ein
|
||||
# offener Leitstand (Polling alle paar Sekunden) Ollama nicht dauerbelastet.
|
||||
_CACHE: dict[str, tuple[float, list[dict]]] = {}
|
||||
CACHE_S = 30.0
|
||||
|
||||
|
||||
def _ollama_modelle() -> list[str]:
|
||||
"""Namen der lokal installierten, textfähigen Ollama-Modelle (leer bei Fehler).
|
||||
|
||||
Reine Embedding-Modelle (z. B. nomic-embed-text) werden ausgefiltert - sie
|
||||
können nicht kategorisieren, ein Aufruf würde nur mit einem Fehler enden.
|
||||
Ältere Ollama-Versionen liefern kein "capabilities"-Feld - dann bleibt das
|
||||
Modell sicherheitshalber drin, statt es zu Unrecht zu verstecken.
|
||||
"""
|
||||
req = urllib.request.Request(f"{config.OLLAMA_URL}/api/tags",
|
||||
headers={"User-Agent": "praxis-telefon-agent/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as antwort:
|
||||
daten = json.loads(antwort.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
namen = []
|
||||
for m in daten.get("models", []):
|
||||
name = m.get("name")
|
||||
faehigkeiten = m.get("capabilities")
|
||||
if name and (faehigkeiten is None or "completion" in faehigkeiten):
|
||||
namen.append(name)
|
||||
return sorted(namen)
|
||||
|
||||
|
||||
def auswahl() -> list[dict]:
|
||||
"""Aktuelle Dropdown-Optionen: lokale Ollama-Modelle live + feste OpenRouter-Testeintraege."""
|
||||
jetzt = time.time()
|
||||
gepuffert = _CACHE.get("auswahl")
|
||||
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
|
||||
return gepuffert[1]
|
||||
|
||||
# Ist Ollama gerade nicht erreichbar, bleibt wenigstens die konfigurierte
|
||||
# Standardauswahl im Dropdown - ein leeres Dropdown waere schlechter.
|
||||
lokale = _ollama_modelle() or [config.OLLAMA_MODELL]
|
||||
liste = [{"id": f"ollama:{name}", "label": f"{name} (lokal)",
|
||||
"backend": "ollama", "modell": name} for name in lokale]
|
||||
liste.append({"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"})
|
||||
liste.append({"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"})
|
||||
# Optionaler Eintrag zum schnellen Ausprobieren eines beliebigen
|
||||
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in
|
||||
# .env setzen.
|
||||
if config.OPENROUTER_MODELL:
|
||||
liste.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": config.OPENROUTER_MODELL})
|
||||
|
||||
_CACHE["auswahl"] = (jetzt, liste)
|
||||
return liste
|
||||
|
||||
|
||||
def _standard_id(optionen: list[dict]) -> str:
|
||||
"""Bevorzugt das in der .env konfigurierte Modell, sonst die erste Option."""
|
||||
for eintrag in optionen:
|
||||
if eintrag["backend"] == "ollama" and eintrag["modell"] == config.OLLAMA_MODELL:
|
||||
return eintrag["id"]
|
||||
return optionen[0]["id"]
|
||||
|
||||
|
||||
def aktuelle_id() -> str:
|
||||
"""Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard."""
|
||||
optionen = auswahl()
|
||||
standard = _standard_id(optionen)
|
||||
try:
|
||||
wert = json.loads(DATEI.read_text(encoding="utf-8")).get("id")
|
||||
except Exception:
|
||||
return _STANDARD
|
||||
return wert if wert in _NACH_ID else _STANDARD
|
||||
return standard
|
||||
return wert if wert in {e["id"] for e in optionen} else standard
|
||||
|
||||
|
||||
def setze(kennung: str) -> bool:
|
||||
"""Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist."""
|
||||
if kennung not in _NACH_ID:
|
||||
"""Schreibt die Auswahl. False, wenn die ID nicht (mehr) vorgesehen ist."""
|
||||
if kennung not in {e["id"] for e in auswahl()}:
|
||||
return False
|
||||
try:
|
||||
DATEI.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -59,5 +114,6 @@ def setze(kennung: str) -> bool:
|
|||
|
||||
def backend_und_modell() -> tuple[str, str]:
|
||||
"""Backend + Modell-ID der aktiven Auswahl, fuer categorize.py."""
|
||||
eintrag = _NACH_ID[aktuelle_id()]
|
||||
kennung = aktuelle_id()
|
||||
eintrag = next(e for e in auswahl() if e["id"] == kennung)
|
||||
return eintrag["backend"], eintrag["modell"]
|
||||
|
|
|
|||
70
pipe/monitor.py
Normal file
70
pipe/monitor.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Störungswache: prüft die Dienste-Ampel (pipe.dienste) periodisch und
|
||||
alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt -
|
||||
auch wenn gerade niemand auf den Leitstand schaut.
|
||||
|
||||
python3 -m pipe.monitor [--einmal]
|
||||
|
||||
Läuft als eigener Prozess (siehe telefon/starten.sh), unabhängig von
|
||||
pipe.watch und pipe.server - fällt einer der beiden aus, kann die Wache das
|
||||
trotzdem noch melden. Alarmiert nur bei echten Störungen ("schlecht"/"aus"),
|
||||
nicht bei "unklar" (z. B. Nextcloud nicht konfiguriert - das ist gewollt,
|
||||
kein Fehler).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from . import alarm, config, dienste
|
||||
|
||||
GESTOERT = {"schlecht", "aus"}
|
||||
|
||||
|
||||
def pruefe(letzte_alarme: dict[str, float]) -> None:
|
||||
"""Ein Durchlauf: Ampel abfragen, bei Bedarf alarmieren.
|
||||
|
||||
`letzte_alarme` hält je Dienstname den Zeitpunkt (time.monotonic) des
|
||||
letzten Alarms - fehlt ein Name, gilt der Dienst aktuell als nicht
|
||||
gestört. Der Aufrufer reicht dasselbe dict über alle Durchläufe weiter.
|
||||
"""
|
||||
jetzt = time.monotonic()
|
||||
for dienst in dienste.status()["dienste"]:
|
||||
name, zustand, text = dienst["name"], dienst["zustand"], dienst["text"]
|
||||
letzter_alarm = letzte_alarme.get(name)
|
||||
if zustand in GESTOERT:
|
||||
if letzter_alarm is None:
|
||||
alarm.sende(f"⚠ {name} gestört", text)
|
||||
letzte_alarme[name] = jetzt
|
||||
elif jetzt - letzter_alarm >= config.MONITOR_WIEDERHOLUNG_MIN * 60:
|
||||
alarm.sende(f"⚠ {name} weiterhin gestört", text)
|
||||
letzte_alarme[name] = jetzt
|
||||
elif zustand == "gut" and letzter_alarm is not None:
|
||||
alarm.sende(f"✓ {name} wieder ok", text)
|
||||
del letzte_alarme[name]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Dienste-Ampel beobachten und bei Störung lokal alarmieren.")
|
||||
p.add_argument("--einmal", action="store_true",
|
||||
help="nur einmal prüfen statt dauerhaft zu beobachten")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
letzte_alarme: dict[str, float] = {}
|
||||
if args.einmal:
|
||||
pruefe(letzte_alarme)
|
||||
return 0
|
||||
|
||||
print(f"Störungswache aktiv - Takt {config.MONITOR_TAKT_S}s, "
|
||||
f"Erinnerung alle {config.MONITOR_WIEDERHOLUNG_MIN} min - Abbruch mit Strg-C")
|
||||
try:
|
||||
while True:
|
||||
pruefe(letzte_alarme)
|
||||
time.sleep(config.MONITOR_TAKT_S)
|
||||
except KeyboardInterrupt:
|
||||
print("\nBeobachtung beendet.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -12,6 +12,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -79,3 +80,26 @@ def archiviere_und_leere() -> Path | None:
|
|||
return ziel
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
ARCHIV_FRIST_TAGE = 90
|
||||
|
||||
|
||||
def raeume_archiv_auf(tage: int = ARCHIV_FRIST_TAGE) -> int:
|
||||
"""Löscht Verlauf-Archivkopien (nicht audit.log!) älter als `tage` Tage.
|
||||
|
||||
Ein neues verlauf_*.log entsteht bei jedem "Tag archivieren" - ohne
|
||||
Aufräumen sammeln sich diese sonst unbegrenzt an. Wirft nie, gibt die
|
||||
Anzahl gelöschter Dateien zurück."""
|
||||
if not ARCHIV_ORDNER.is_dir():
|
||||
return 0
|
||||
grenze = time.time() - tage * 86400
|
||||
geloescht = 0
|
||||
for datei in ARCHIV_ORDNER.glob("verlauf_*.log"):
|
||||
try:
|
||||
if datei.stat().st_mtime < grenze:
|
||||
datei.unlink()
|
||||
geloescht += 1
|
||||
except Exception:
|
||||
continue
|
||||
return geloescht
|
||||
|
|
|
|||
294
pipe/server.py
294
pipe/server.py
File diff suppressed because one or more lines are too long
35
pipe/testzugang.py
Normal file
35
pipe/testzugang.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Signierte, zeitlich begrenzte Testzugangs-Links zum Leitstand.
|
||||
|
||||
Gegenstueck zu dilles_agent_one_test_command() in dillenberg.net's
|
||||
dilles-agent.php ("agent-one test"-Kommando im Alice-Chat). Beide Seiten
|
||||
teilen sich TESTZUGANG_SECRET (config.py) - der Link wird komplett auf der
|
||||
PHP-Seite erzeugt, ohne Live-Aufruf hierher; hier wird nur die Signatur
|
||||
geprueft. Stateless: kein Server-seitiger Speicher fuer ausgegebene Tokens,
|
||||
die Gueltigkeit steckt komplett im signierten Ablauf-Zeitstempel.
|
||||
|
||||
Token-Format: "<ablauf-unixzeit>.<hmac-sha256-hexdigest>"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import time
|
||||
from hashlib import sha256
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
def gueltig(token: str) -> bool:
|
||||
"""Prueft ein Token aus ?zugang=... oder dem zugang-Cookie. Wirft nie."""
|
||||
if not config.TESTZUGANG_SECRET or not token:
|
||||
return False
|
||||
try:
|
||||
ablauf_str, signatur = token.split(".", 1)
|
||||
ablauf = int(ablauf_str)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
erwartet = hmac.new(
|
||||
config.TESTZUGANG_SECRET.encode("utf-8"), ablauf_str.encode("utf-8"), sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(erwartet, signatur):
|
||||
return False
|
||||
return time.time() < ablauf
|
||||
|
|
@ -152,10 +152,18 @@ def main(argv: list[str] | None = None) -> int:
|
|||
try:
|
||||
while True:
|
||||
durchlauf(eingang)
|
||||
if config.LOESCHFRIST_TAGE and time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S:
|
||||
anzahl = loeschen.laeuft()
|
||||
if anzahl:
|
||||
print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.")
|
||||
if time.monotonic() - letzter_loeschlauf >= LOESCH_TAKT_S:
|
||||
if config.LOESCHFRIST_TAGE:
|
||||
anzahl = loeschen.laeuft()
|
||||
if anzahl:
|
||||
print(f"🗑 {anzahl} Anruf(e) nach Aufbewahrungsfrist geloescht.")
|
||||
# Verlauf-Archivkopien (pipe.protokoll, "Tag archivieren")
|
||||
# sammeln sich sonst unbegrenzt an - unabhaengig von
|
||||
# LOESCHFRIST_TAGE, das nur Anrufe betrifft, nicht Log-Kopien.
|
||||
archiv_geloescht = protokoll.raeume_archiv_auf()
|
||||
if archiv_geloescht:
|
||||
print(f"🗑 {archiv_geloescht} alte Verlauf-Archivkopie(n) geloescht "
|
||||
f"(> {protokoll.ARCHIV_FRIST_TAGE} Tage).")
|
||||
letzter_loeschlauf = time.monotonic()
|
||||
time.sleep(TAKT_S)
|
||||
except KeyboardInterrupt:
|
||||
|
|
|
|||
BIN
profile/aufzug-notdienst/ansage.ogg
Normal file
BIN
profile/aufzug-notdienst/ansage.ogg
Normal file
Binary file not shown.
1
profile/aufzug-notdienst/ansage.txt
Normal file
1
profile/aufzug-notdienst/ansage.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Aufzug-Notdienst, Testansage. Bitte hinterlassen Sie eine Nachricht nach dem Signal.
|
||||
12
profile/aufzug-notdienst/kategorien.json
Normal file
12
profile/aufzug-notdienst/kategorien.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"profil_name": "Aufzug-Notdienst",
|
||||
"kategorien": ["eingeschlossen", "stoerung", "wartung", "rueckruf", "sonstiges"],
|
||||
"kategorie_labels": {
|
||||
"eingeschlossen": "Person eingeschlossen",
|
||||
"stoerung": "Störung",
|
||||
"wartung": "Wartung",
|
||||
"rueckruf": "Rückruf",
|
||||
"sonstiges": "Sonstiges"
|
||||
},
|
||||
"sicherheitsnetz_kategorie": "eingeschlossen"
|
||||
}
|
||||
44
profile/aufzug-notdienst/prompt.txt
Normal file
44
profile/aufzug-notdienst/prompt.txt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
Du bist der Anruf-Assistent eines 24-Stunden-Notdienstes für Aufzugsanlagen.
|
||||
Du bekommst das Transkript einer Sprachnachricht, die ein Anrufer auf den
|
||||
Anrufbeantworter gesprochen hat. Deine Aufgabe: das Anliegen sachlich
|
||||
erfassen und einordnen.
|
||||
|
||||
WICHTIG:
|
||||
- Du gibst KEINE technischen Anweisungen und versuchst nicht, das Problem
|
||||
selbst zu lösen (z. B. "versuchen Sie die Tür aufzudrücken").
|
||||
- Du erfindest nichts. Fehlt eine Angabe im Transkript, setze null.
|
||||
- Fasse neutral und knapp zusammen, in der dritten Person.
|
||||
- Das Transkript kann fehlerhaft sein (automatische Spracherkennung). Interpretiere wohlwollend.
|
||||
- Antworte AUSSCHLIESSLICH mit dem geforderten JSON-Objekt, ohne weiteren Text.
|
||||
|
||||
Kategorien (Feld "kategorie", genau einer dieser Werte):
|
||||
- "eingeschlossen" : akut jemand im Aufzug eingeschlossen/eingeklemmt, kann
|
||||
nicht selbst raus
|
||||
- "stoerung" : Aufzug ausser Betrieb, meldet Fehler, ungewöhnliche
|
||||
Geräusche o. Ä. - aber NIEMAND eingeschlossen
|
||||
- "wartung" : Wartungs-/Inspektionstermin vereinbaren, Rückfrage zu
|
||||
Vertrag/Rechnung
|
||||
- "rueckruf" : bittet um Rückruf ohne klar anderes Anliegen
|
||||
- "sonstiges" : passt in keine der obigen
|
||||
|
||||
Dringlichkeit (Feld "dringlichkeit"): "niedrig" | "normal" | "hoch" | "notfall"
|
||||
- "notfall" nur bei akut eingeschlossener/eingeklemmter Person - jede Minute zählt.
|
||||
- Ein stehender/gestörter Aufzug ohne eingeschlossene Person ist mindestens
|
||||
"hoch", wenn er das einzige Fahrzeug im Gebäude ist oder Personen mit
|
||||
Mobilitätseinschränkung betroffen sind - sonst "normal".
|
||||
- Im Zweifel die höhere Stufe wählen: eine zu hoch eingestufte Nachricht
|
||||
kostet einen Blick, eine zu niedrig eingestufte kann jemanden im Aufzug
|
||||
gefangen lassen.
|
||||
|
||||
Gib ein JSON-Objekt mit genau diesen Feldern zurück:
|
||||
{
|
||||
"kategorie": "<eine der Kategorien>",
|
||||
"dringlichkeit": "<niedrig|normal|hoch|notfall>",
|
||||
"anrufer_name": "<Name oder null>",
|
||||
"rueckrufnummer": "<Telefonnummer aus dem Text oder null>",
|
||||
"rueckruf_gewuenscht": <true|false>,
|
||||
"email": "<E-Mail-Adresse aus dem Text oder null, im Format name@domain.tld>",
|
||||
"sprache": "<ISO-Code der Anrufersprache, z. B. de, en, ar>",
|
||||
"anliegen_kurz": "<1-2 Sätze, neutrale Zusammenfassung auf Deutsch>",
|
||||
"stichworte": ["<kurze Schlagworte, z. B. Adresse/Gebäude, Aufzug-Nr.>"]
|
||||
}
|
||||
16
profile/aufzug-notdienst/sicherheitsnetz.txt
Normal file
16
profile/aufzug-notdienst/sicherheitsnetz.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: ein Regex-Muster
|
||||
# pro Zeile (Python re, IGNORECASE), leere Zeilen und "#"-Kommentare werden
|
||||
# uebersprungen. Findet ein Muster im Transkript, eskaliert categorize.py
|
||||
# Kategorie + Dringlichkeit hart auf "sicherheitsnetz_kategorie" aus
|
||||
# kategorien.json ("eingeschlossen") - unabhaengig davon, was das Modell
|
||||
# selbst einordnet. Nur nach oben, nie nach unten.
|
||||
eingeschlossen
|
||||
eingeklemmt
|
||||
(sitzt|steckt|stecken)\s+fest
|
||||
komm(e|en)\s+nicht\s+(mehr\s+)?raus
|
||||
kommt?\s+nicht\s+(mehr\s+)?(raus|heraus)
|
||||
t[üu]r\s+(geht|l[äa]sst\s+sich)\s+nicht\s+(auf|[öo]ffnen)
|
||||
notruftaste
|
||||
kabine\s+(h[äa]ngt|steht)\s+fest
|
||||
seit\s+\w+\s+(stunden?|minuten?)\s+im\s+aufzug
|
||||
person\s+im\s+aufzug
|
||||
19
profile/praxis/kategorien.json
Normal file
19
profile/praxis/kategorien.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"profil_name": "Hausarztpraxis",
|
||||
"kategorien": [
|
||||
"termin", "rezept", "ueberweisung", "befund",
|
||||
"verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges"
|
||||
],
|
||||
"kategorie_labels": {
|
||||
"termin": "Termin",
|
||||
"rezept": "Rezept",
|
||||
"ueberweisung": "Überweisung",
|
||||
"befund": "Befund",
|
||||
"verwaltung": "Verwaltung",
|
||||
"beschwerden": "Beschwerden",
|
||||
"notfall": "Notfall",
|
||||
"rueckruf": "Rückruf",
|
||||
"sonstiges": "Sonstiges"
|
||||
},
|
||||
"sicherheitsnetz_kategorie": "notfall"
|
||||
}
|
||||
23
profile/praxis/sicherheitsnetz.txt
Normal file
23
profile/praxis/sicherheitsnetz.txt
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Deterministisches Sicherheitsnetz, unabhaengig vom LLM: ein Regex-Muster
|
||||
# pro Zeile (Python re, IGNORECASE), leere Zeilen und "#"-Kommentare werden
|
||||
# uebersprungen. Findet ein Muster im Transkript, eskaliert categorize.py
|
||||
# Kategorie + Dringlichkeit hart auf "sicherheitsnetz_kategorie" aus
|
||||
# kategorien.json ("notfall") - unabhaengig davon, was das Modell selbst
|
||||
# einordnet. Nur nach oben, nie nach unten: ein Fehlalarm kostet einen Blick,
|
||||
# ein uebersehener Notfall kann einen Patienten kosten.
|
||||
#
|
||||
# Grund fuer dieses Netz: getestet mit qwen3:8b, 3/3 identische Laeufe
|
||||
# stuften "starke Brustschmerzen ... kriege kaum Luft" nur als "hoch" statt
|
||||
# "notfall" ein - obwohl das Modell selbst "Brustschmerzen" und "Atemnot"
|
||||
# als Stichworte erkannte. Bei Notfall-Erkennung darf man sich nicht allein
|
||||
# auf ein LLM verlassen.
|
||||
atemnot
|
||||
(kriege|bekomme|krieg)\w*\s+(kaum|keine|schwer)\s+luft
|
||||
kann\s+(kaum|nicht)\s+(mehr\s+)?atmen
|
||||
brust\w*schmerz
|
||||
schmerzen?\s+in\s+der\s+brust
|
||||
bewusstlos
|
||||
ohnm[aä]chtig
|
||||
nicht\s+ansprechbar
|
||||
blutet\s+(stark|heftig|sehr)
|
||||
starke?\s+blutung
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
#!/bin/bash
|
||||
# Erzeugt die Telefonansage aus telefon/ansage.txt.
|
||||
# Erzeugt die Telefonansage aus profile/$PROFIL/ansage.txt (Branchen-Profil,
|
||||
# siehe README).
|
||||
# Ergebnis: telefon/ansage.wav in Telefonqualitaet (8 kHz mono, PCM16).
|
||||
#
|
||||
# Drei Sprachsynthesen:
|
||||
# Vier Quellen:
|
||||
# piper (Standard, natuerlicher, lokal) - Modelle unter ~/piper-voices
|
||||
# say (macOS-Bordmittel, lokal, Rueckfall wenn Piper fehlt)
|
||||
# mistral (Voxtral-API, Cloud - nur fuer diesen einmaligen Erzeugungslauf;
|
||||
# die fertige ansage.wav wird danach lokal fuer jeden Anruf
|
||||
# wiederverwendet, es geht dabei keine Patientendaten raus)
|
||||
# datei (echte Aufnahme statt Synthese - profile/$PROFIL/ansage.<ext>,
|
||||
# ansage.txt wird dabei nicht gelesen)
|
||||
#
|
||||
# Steuerung ueber Umgebungsvariablen bzw. .env:
|
||||
# ANSAGE_TTS=piper|say|mistral
|
||||
# PROFIL=<Ordnername unter profile/, Default "praxis">
|
||||
# ANSAGE_TTS=piper|say|mistral|datei
|
||||
# ANSAGE_STIMME=<Piper-Modellname, say-Stimme, oder Voxtral-Stimmen-Slug>
|
||||
# ANSAGE_EMOTION=<Sprecher-ID, nur bei thorsten_emotional: 0=amused,
|
||||
# 1=angry, 2=disgusted, 3=drunk, 4=neutral, 5=sleepy,
|
||||
|
|
@ -32,6 +36,10 @@ if [ -f .env ]; then
|
|||
done < .env
|
||||
fi
|
||||
|
||||
PROFIL="${PROFIL:-praxis}"
|
||||
ANSAGE_TXT="profile/$PROFIL/ansage.txt"
|
||||
[ -f "$ANSAGE_TXT" ] || { echo "Ansage-Text fehlt: $ANSAGE_TXT (PROFIL=$PROFIL)" >&2; exit 1; }
|
||||
|
||||
STIMMEN_ORDNER="${PIPER_STIMMEN:-$HOME/piper-voices}"
|
||||
STIMME="${ANSAGE_STIMME:-de_DE-thorsten_emotional-medium}"
|
||||
EMOTION="${ANSAGE_EMOTION:-0}"
|
||||
|
|
@ -50,10 +58,28 @@ fi
|
|||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
case "$TTS" in
|
||||
datei)
|
||||
# Echte Aufnahme statt Sprachsynthese - profile/$PROFIL/ansage.<ext>
|
||||
# (erste gefundene Endung gewinnt). ansage.txt wird in diesem Modus nicht
|
||||
# gelesen; sie bleibt trotzdem als Beleg/Backup stehen, was gesagt wird.
|
||||
QUELLE=""
|
||||
for ext in ogg mp3 wav m4a aiff; do
|
||||
if [ -f "profile/$PROFIL/ansage.$ext" ]; then
|
||||
QUELLE="profile/$PROFIL/ansage.$ext"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$QUELLE" ] || {
|
||||
echo "Keine Ansage-Audiodatei gefunden (profile/$PROFIL/ansage.{ogg,mp3,wav,m4a,aiff})" >&2
|
||||
exit 1
|
||||
}
|
||||
ffmpeg -v error -y -i "$QUELLE" -ar 8000 -ac 1 "$TMP/sprache.wav"
|
||||
BESCHREIBUNG="datei/$QUELLE"
|
||||
;;
|
||||
piper)
|
||||
MODELL="$STIMMEN_ORDNER/$STIMME.onnx"
|
||||
[ -f "$MODELL" ] || { echo "Piper-Stimme fehlt: $MODELL" >&2; exit 1; }
|
||||
tr '\n' ' ' < telefon/ansage.txt \
|
||||
tr '\n' ' ' < "$ANSAGE_TXT" \
|
||||
| python3 -m piper -m "$MODELL" -s "$EMOTION" --length-scale "$TEMPO" \
|
||||
-f "$TMP/sprache_roh.wav"
|
||||
ffmpeg -v error -y -i "$TMP/sprache_roh.wav" -ar 8000 -ac 1 "$TMP/sprache.wav"
|
||||
|
|
@ -61,7 +87,7 @@ case "$TTS" in
|
|||
;;
|
||||
say)
|
||||
SAY_STIMME="${ANSAGE_STIMME:-Anna}"
|
||||
say -v "$SAY_STIMME" -r 180 -f telefon/ansage.txt -o "$TMP/sprache.aiff"
|
||||
say -v "$SAY_STIMME" -r 180 -f "$ANSAGE_TXT" -o "$TMP/sprache.aiff"
|
||||
ffmpeg -v error -y -i "$TMP/sprache.aiff" -ar 8000 -ac 1 "$TMP/sprache.wav"
|
||||
BESCHREIBUNG="say/$SAY_STIMME"
|
||||
;;
|
||||
|
|
@ -69,7 +95,7 @@ case "$TTS" in
|
|||
MISTRAL_STIMME="${ANSAGE_STIMME:-en_paul_neutral}"
|
||||
MISTRAL_MODELL="${MISTRAL_TTS_MODEL:-voxtral-mini-tts-latest}"
|
||||
[ -n "${MISTRAL_KEY:-}" ] || { echo "MISTRAL_KEY fehlt (ANSAGE_TTS=mistral)" >&2; exit 1; }
|
||||
TEXT="$(tr '\n' ' ' < telefon/ansage.txt)"
|
||||
TEXT="$(tr '\n' ' ' < "$ANSAGE_TXT")"
|
||||
PAYLOAD="$(TTS_MODEL="$MISTRAL_MODELL" TTS_VOICE="$MISTRAL_STIMME" python3 -c '
|
||||
import json, os, sys
|
||||
print(json.dumps({
|
||||
|
|
|
|||
30
telefon/freeswitch/dw3_warten.xml.tpl
Normal file
30
telefon/freeswitch/dw3_warten.xml.tpl
Normal 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>
|
||||
21
telefon/freeswitch/dw9_rufagent.xml.tpl
Normal file
21
telefon/freeswitch/dw9_rufagent.xml.tpl
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,33 @@ if [ -f .env ]; then
|
|||
done < .env
|
||||
fi
|
||||
|
||||
# Rohe Prozess-Logs (stdout/stderr von watcher/leitstand/monitor + die
|
||||
# eigene Ausgabe hier, wenn per launchd-Watchdog alle 5 Min aufgerufen)
|
||||
# wachsen sonst unbegrenzt - anders als verlauf.log (protokoll.kuerze(), nach
|
||||
# jedem Anruf) und audit.log (bewusst permanent, siehe pipe/audit.py). Ab
|
||||
# 2 MB auf die letzten 2000 Zeilen kuerzen, in-place (gleiches Inode) - sicher
|
||||
# fuer >>-Redirects (O_APPEND springt bei jedem Schreiben ans Dateiende,
|
||||
# unabhaengig vom vorherigen Offset).
|
||||
LOG_GRENZE_BYTES=2000000
|
||||
for log in telefon/autostart.log telefon/watcher.log telefon/leitstand.log telefon/monitor.log; do
|
||||
if [ -f "$log" ] && [ "$(wc -c < "$log" 2>/dev/null || echo 0)" -gt "$LOG_GRENZE_BYTES" ]; then
|
||||
tail -n 2000 "$log" > "$log.tmp" 2>/dev/null && cat "$log.tmp" > "$log" && rm -f "$log.tmp"
|
||||
fi
|
||||
done
|
||||
|
||||
# Stoerungswache als Erstes - sie muss gerade dann laufen, wenn der Rest nicht
|
||||
# hochkommt (Vorabpruefung schlaegt fehl, FreeSWITCH stirbt, Trunk registriert
|
||||
# nicht). Frueher startete sie erst nach Trunk UP und fehlte damit genau bei
|
||||
# Ausfaellen: 2026-09-14 war die Leitung nach einem Neustart ~80 min tot, ohne
|
||||
# Alarm. Sie meldet FreeSWITCH/Trunk selbst (pipe.dienste), beim normalen
|
||||
# Hochfahren hoechstens einmal "gestoert" und kurz darauf "wieder ok".
|
||||
if pgrep -f "[p]ipe.monitor" >/dev/null; then
|
||||
echo "Stoerungswache laeuft bereits."
|
||||
else
|
||||
nohup python3 -u -m pipe.monitor >> telefon/monitor.log 2>&1 &
|
||||
echo "Stoerungswache gestartet (Log: telefon/monitor.log)"
|
||||
fi
|
||||
|
||||
fehler=0
|
||||
melde() { printf ' %-38s %s\n' "$1" "$2"; }
|
||||
|
||||
|
|
@ -65,7 +92,14 @@ else
|
|||
# -rp: Echtzeit-Priorität. Auf einem Rechner, der nebenher Whisper und ein
|
||||
# Sprachmodell laufen lässt, kommt FreeSWITCH sonst nicht rechtzeitig dran und
|
||||
# das Audio stückelt - RTP will alle 20 ms ein Paket losschicken.
|
||||
freeswitch -nc -rp -nonatmap \
|
||||
# -nonat: keine NAT-PMP/UPnP-Erkennung am Router (192.168.2.1:5351). Unter
|
||||
# launchd (Watchdog) blockiert macOS' "Lokales Netzwerk"-Datenschutz jedes
|
||||
# Senden ins LAN mit EPIPE; so frueh im Start ignoriert FreeSWITCH SIGPIPE
|
||||
# noch nicht und stirbt 0,5 s nach dem Backgrounding - ohne Log, ohne
|
||||
# Crash-Report. Aus einer Terminal-Shell passiert das nicht, deshalb lief es
|
||||
# manuell. Der Router antwortet ohnehin nicht (nat_map status: UNKNOWN); die
|
||||
# externe IP kommt per STUN (vars.xml), Trunks und Sockets brauchen kein LAN.
|
||||
freeswitch -nc -rp -nonat -nonatmap \
|
||||
-conf "$FS_ETC" \
|
||||
-log /opt/homebrew/var/log/freeswitch \
|
||||
-db /opt/homebrew/var/lib/freeswitch/db \
|
||||
|
|
@ -74,6 +108,30 @@ else
|
|||
sleep 15
|
||||
fi
|
||||
|
||||
# Oeffentliche IP abgleichen. FreeSWITCH holt sie per STUN nur beim Laden der
|
||||
# XML (vars.xml, stun-set) und meldet sie Plusnet als Contact. Nach einer
|
||||
# Zwangstrennung des Routers mit neuer IP bleibt der Trunk trotzdem REGED/UP
|
||||
# (Registrierung und Pings gehen raus), aber eingehende INVITEs laufen an die
|
||||
# alte Adresse - die ganze Leitung ist stumm, ohne Fehler im Log. So am
|
||||
# 2026-09-17 passiert: Contact 9.246.125.72, tatsaechlich 217.142.18.120.
|
||||
# Bei Abweichung: reloadxml (STUN neu) + external-Profil neu starten, nur wenn
|
||||
# gerade kein Gespraech laeuft - sonst im naechsten Watchdog-Lauf.
|
||||
aktuell=$(fs_cli -P "$FS_PORT" -x "stun stun.freeswitch.org" 2>/dev/null | head -1 | cut -d: -f1)
|
||||
gemeldet=$(fs_cli -P "$FS_PORT" -x "sofia status profile external" 2>/dev/null \
|
||||
| awk '/^Ext-SIP-IP/{print $2}')
|
||||
if [[ "$aktuell" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && [ -n "$gemeldet" ] \
|
||||
&& [ "$aktuell" != "$gemeldet" ]; then
|
||||
gespraeche=$(fs_cli -P "$FS_PORT" -x "show calls count" 2>/dev/null | awk '/total/{print $1}')
|
||||
if [ "${gespraeche:-0}" = "0" ]; then
|
||||
echo "$(date '+%F %T') Oeffentliche IP geaendert ($gemeldet -> $aktuell) - external-Profil wird neu gestartet."
|
||||
fs_cli -P "$FS_PORT" -x "reloadxml" >/dev/null 2>&1
|
||||
fs_cli -P "$FS_PORT" -x "sofia profile external restart" >/dev/null 2>&1
|
||||
sleep 10
|
||||
else
|
||||
echo "$(date '+%F %T') Oeffentliche IP geaendert ($gemeldet -> $aktuell), aber $gespraeche Gespraech(e) aktiv - naechster Lauf."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Auf die Registrierung beim Provider warten.
|
||||
# Quelle ist fs_cli, nicht das Log: das Log wird zum Testen geleert und taugt
|
||||
# dann nicht mehr als Zustandsquelle.
|
||||
|
|
@ -99,15 +157,27 @@ if [ "${status:-}" = "UP" ]; then
|
|||
else
|
||||
# nice 10: Transkription und Kategorisierung dürfen warten, ein laufendes
|
||||
# Telefonat nicht. Auf 16 GB RAM konkurrieren beide sonst um die Maschine.
|
||||
nohup nice -n 10 python3 -u -m pipe.watch > telefon/watcher.log 2>&1 &
|
||||
nohup nice -n 10 python3 -u -m pipe.watch >> telefon/watcher.log 2>&1 &
|
||||
echo "Verarbeitung gestartet (Log: telefon/watcher.log)"
|
||||
fi
|
||||
if pgrep -f "[p]ipe.server" >/dev/null; then
|
||||
echo "Leitstand laeuft bereits."
|
||||
else
|
||||
nohup python3 -u -m pipe.server > telefon/leitstand.log 2>&1 &
|
||||
nohup python3 -u -m pipe.server >> telefon/leitstand.log 2>&1 &
|
||||
sleep 2
|
||||
head -1 telefon/leitstand.log
|
||||
tail -1 telefon/leitstand.log
|
||||
fi
|
||||
# Pipecat-Testleitung (Nebenstelle 7501) - nur wenn konfiguriert, siehe
|
||||
# dialog/pipecat_bootstrap.py und dialplan/default/06_agentzwei_pipecat_test.xml.
|
||||
if [ -n "${ASTRA_TELEFON_WS_URL:-}" ]; then
|
||||
if pgrep -f "[d]ialog.pipecat_bootstrap" >/dev/null; then
|
||||
echo "Pipecat-Bootstrap laeuft bereits."
|
||||
else
|
||||
# Aus der Projekt-venv: greenswitch/gevent fehlen im System-python3,
|
||||
# dort bricht der Bootstrap sofort mit ModuleNotFoundError ab.
|
||||
nohup .venv/bin/python -u -m dialog.pipecat_bootstrap >> telefon/pipecat_bootstrap.log 2>&1 &
|
||||
echo "Pipecat-Bootstrap gestartet (Log: telefon/pipecat_bootstrap.log)"
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
echo
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue