mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-16 18:26:12 +02:00
Praxis-Telefon-Agent: Anrufannahme, Transkription, Triage — vollständig on-prem
Nimmt Anrufe einer Hausarztpraxis entgegen, transkribiert sie lokal, kategorisiert das Anliegen und legt es strukturiert ab. Keine Patientendaten verlassen den Rechner: Spracherkennung, Sprachmodell und Sprachsynthese laufen lokal, im Datenpfad steht kein Cloud-Dienst. Aufbau: - FreeSWITCH nimmt über einen SIP-Trunk an, spielt die Ansage und nimmt auf. - pipe.watch erkennt fertige Aufnahmen und schiebt sie durch die Pipe. - whisper.cpp transkribiert, ein lokales Sprachmodell (Ollama) ordnet in Kategorie und Dringlichkeit ein — mit erzwungenem JSON-Schema. - Ablage lokal, optional Nextcloud (WebDAV) und ein Deck-Board. - Leitstand (pipe.server): Board mit Eingang/In Bearbeitung/Rückfragen/Erledigt, Aufnahmen zum Anhören, Verlauf und Ampeln für alle beteiligten Dienste. Zugangsdaten und Anrufdaten liegen außerhalb des Repositorys (.env, ablage/, telefon/). Konfiguration siehe .env.example, Einrichtung siehe README.
This commit is contained in:
commit
1a1280e79d
24 changed files with 2253 additions and 0 deletions
133
pipe/watch.py
Normal file
133
pipe/watch.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Beobachtet den Eingangsordner und schiebt neue Aufnahmen durch die Pipe.
|
||||
|
||||
python3 -m pipe.watch [--einmal]
|
||||
|
||||
Die Telefonanlage legt Aufnahmen in ``telefon/eingang`` ab. Der Dateiname trägt
|
||||
die Rufnummer des Anrufers (CLIP), damit sie nicht verloren geht:
|
||||
|
||||
JJJJMMTT-HHMMSS_<nummer>.wav
|
||||
|
||||
Verarbeitete Aufnahmen wandern nach ``telefon/verarbeitet``, gescheiterte nach
|
||||
``telefon/fehler`` — dort bleiben sie liegen, statt still verloren zu gehen.
|
||||
|
||||
Bewusst mit Polling statt Dateisystem-Ereignissen: eine Praxis bekommt Anrufe im
|
||||
Minutenabstand, nicht im Millisekundenabstand, und Polling übersteht Neustarts
|
||||
und Netzlaufwerke ohne Sonderfälle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, process_call, protokoll
|
||||
|
||||
TAKT_S = 3 # Wartezeit zwischen zwei Durchläufen
|
||||
RUHE_S = 2 # So lange muss die Dateigröße stabil sein (Aufnahme fertig)
|
||||
ENDUNGEN = {".wav", ".mp3", ".opus", ".ogg", ".m4a", ".aiff"}
|
||||
|
||||
# JJJJMMTT-HHMMSS_<nummer>.<ext> - beides optional, damit auch handverlesene
|
||||
# Dateien verarbeitet werden.
|
||||
_NAME = re.compile(r"^(?:(\d{8})-(\d{6}))?_?(.*)$")
|
||||
|
||||
|
||||
def _nummer_und_zeit(datei: Path) -> tuple[str | None, datetime | None]:
|
||||
"""Liest Rufnummer und Aufnahmezeit aus dem Dateinamen."""
|
||||
treffer = _NAME.match(datei.stem)
|
||||
if not treffer:
|
||||
return None, None
|
||||
tag, uhrzeit, rest = treffer.groups()
|
||||
zeit = None
|
||||
if tag and uhrzeit:
|
||||
try:
|
||||
zeit = datetime.strptime(f"{tag}{uhrzeit}", "%Y%m%d%H%M%S")
|
||||
except ValueError:
|
||||
zeit = None
|
||||
nummer = rest.strip() or None
|
||||
if nummer in {"unbekannt", "anonymous", "restricted", "0"}:
|
||||
nummer = None
|
||||
return nummer, zeit
|
||||
|
||||
|
||||
def _fertig(datei: Path) -> bool:
|
||||
"""Wahr, wenn die Datei nicht mehr wächst - die Aufnahme also steht."""
|
||||
try:
|
||||
groesse = datei.stat().st_size
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
if groesse == 0:
|
||||
return False
|
||||
time.sleep(RUHE_S)
|
||||
try:
|
||||
return datei.stat().st_size == groesse
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _neue_dateien(eingang: Path):
|
||||
for datei in sorted(eingang.iterdir()):
|
||||
if datei.is_file() and datei.suffix.lower() in ENDUNGEN and not datei.name.startswith("."):
|
||||
yield datei
|
||||
|
||||
|
||||
def verarbeite_datei(datei: Path) -> bool:
|
||||
"""Schiebt eine Aufnahme durch die Pipe und räumt sie weg. True bei Erfolg."""
|
||||
nummer, zeit = _nummer_und_zeit(datei)
|
||||
print(f"\n▸ Neue Aufnahme: {datei.name}"
|
||||
f"{f' (Rufnummer {nummer})' if nummer else ' (Rufnummer unbekannt)'}")
|
||||
protokoll.schreibe("eingang", f"Neue Aufnahme {datei.name}", nummer=nummer)
|
||||
try:
|
||||
process_call.verarbeite(str(datei), anrufer_nummer=nummer, empfangen=zeit)
|
||||
except Exception as fehler:
|
||||
print(f"✖ Verarbeitung fehlgeschlagen: {fehler}", file=sys.stderr)
|
||||
protokoll.schreibe("fehler", f"{datei.name}: {fehler}", nummer=nummer)
|
||||
config.TELEFON_FEHLER.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(datei), config.TELEFON_FEHLER / datei.name)
|
||||
print(f" Aufnahme liegt in {config.TELEFON_FEHLER}/{datei.name} und geht nicht verloren.")
|
||||
return False
|
||||
config.TELEFON_VERARBEITET.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(datei), config.TELEFON_VERARBEITET / datei.name)
|
||||
return True
|
||||
|
||||
|
||||
def durchlauf(eingang: Path) -> int:
|
||||
"""Verarbeitet alles, was gerade fertig im Eingang liegt."""
|
||||
zahl = 0
|
||||
for datei in _neue_dateien(eingang):
|
||||
if _fertig(datei):
|
||||
verarbeite_datei(datei)
|
||||
zahl += 1
|
||||
return zahl
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Eingangsordner beobachten und Anrufe verarbeiten.")
|
||||
p.add_argument("--einmal", action="store_true",
|
||||
help="nur einmal durchlaufen statt dauerhaft beobachten")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
eingang = config.TELEFON_EINGANG
|
||||
eingang.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.einmal:
|
||||
zahl = durchlauf(eingang)
|
||||
print(f"{zahl} Aufnahme(n) verarbeitet.")
|
||||
return 0
|
||||
|
||||
print(f"Beobachte {eingang} — Abbruch mit Strg-C")
|
||||
protokoll.schreibe("system", "Watcher gestartet")
|
||||
try:
|
||||
while True:
|
||||
durchlauf(eingang)
|
||||
time.sleep(TAKT_S)
|
||||
except KeyboardInterrupt:
|
||||
print("\nBeobachtung beendet.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue