mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +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
162
pipe/deck.py
Normal file
162
pipe/deck.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Nextcloud-Deck-Anbindung: pro Anruf eine Triage-Karte.
|
||||
|
||||
Aufbau:
|
||||
- Ein Board (Standard: "Anrufe").
|
||||
- Ein Stapel je Kategorie (termin, rezept, …) — wird bei Bedarf angelegt.
|
||||
- Ein Label je Dringlichkeit (farbig) — wird bei Bedarf angelegt und der Karte
|
||||
zugewiesen.
|
||||
- Karte je Anruf: Titel kompakt, Beschreibung = die Zusammenfassung (Markdown).
|
||||
|
||||
Nur aktiv, wenn NEXTCLOUD_DECK=true. Alles über die Deck-REST-API (v1.1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from functools import lru_cache
|
||||
|
||||
from . import config
|
||||
|
||||
_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
|
||||
}
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _ssl_kontext() -> ssl.SSLContext:
|
||||
try:
|
||||
import certifi
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def _api(method: str, pfad: str, koerper: dict | None = None):
|
||||
roh = f"{config.NEXTCLOUD_USER}:{config.NEXTCLOUD_PASS}".encode("utf-8")
|
||||
headers = {
|
||||
"Authorization": "Basic " + base64.b64encode(roh).decode("ascii"),
|
||||
"OCS-APIRequest": "true",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "praxis-telefon-agent/1.0",
|
||||
}
|
||||
daten = json.dumps(koerper).encode("utf-8") if koerper is not None else None
|
||||
req = urllib.request.Request(
|
||||
f"{config.NEXTCLOUD_URL}{_API}{pfad}", data=daten, headers=headers, method=method
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30, context=_ssl_kontext()) as antwort:
|
||||
text = antwort.read().decode("utf-8")
|
||||
return json.loads(text) if text.strip() else None
|
||||
|
||||
|
||||
# --- Board / Stapel / Labels sicherstellen -----------------------------------
|
||||
|
||||
def _freigabe(board_id: int, acl: list | None) -> None:
|
||||
"""Teilt das Board mit den Nutzern aus DECK_TEILEN, falls noch nicht geschehen.
|
||||
|
||||
Das Board gehört dem Konto, mit dem die Pipe arbeitet. Ohne Freigabe legt
|
||||
sie zwar Karten an, die Praxis sieht aber ein leeres Deck.
|
||||
"""
|
||||
if not config.DECK_TEILEN:
|
||||
return
|
||||
schon = {(a.get("participant") or {}).get("uid") for a in (acl or [])}
|
||||
for nutzer in config.DECK_TEILEN:
|
||||
if nutzer in schon:
|
||||
continue
|
||||
try:
|
||||
_api("POST", f"/boards/{board_id}/acl",
|
||||
{"type": 0, "participant": nutzer, "permissionEdit": True,
|
||||
"permissionShare": False, "permissionManage": False})
|
||||
print(f" → Deck: Board mit '{nutzer}' geteilt")
|
||||
except Exception as fehler:
|
||||
print(f" → Deck: Freigabe für '{nutzer}' fehlgeschlagen ({fehler})")
|
||||
|
||||
|
||||
def _board_id() -> int:
|
||||
for board in _api("GET", "/boards") or []:
|
||||
if board.get("title") == config.DECK_BOARD and not board.get("archived"):
|
||||
_freigabe(board["id"], board.get("acl"))
|
||||
return board["id"]
|
||||
neu = _api("POST", "/boards", {"title": config.DECK_BOARD, "color": "0082C9"})
|
||||
_freigabe(neu["id"], [])
|
||||
return neu["id"]
|
||||
|
||||
|
||||
def _stapel(board_id: int) -> dict[str, int]:
|
||||
"""Stellt die Stapel des Arbeitsablaufs sicher und gibt sie zurück."""
|
||||
vorhanden = {s["title"]: s["id"] for s in (_api("GET", f"/boards/{board_id}/stacks") or [])}
|
||||
for i, name in enumerate(config.DECK_STAPEL):
|
||||
if name not in vorhanden:
|
||||
neu = _api("POST", f"/boards/{board_id}/stacks", {"title": name, "order": i})
|
||||
vorhanden[name] = neu["id"]
|
||||
return vorhanden
|
||||
|
||||
|
||||
def _labels(board_id: int) -> dict[str, int]:
|
||||
board = _api("GET", f"/boards/{board_id}")
|
||||
vorhanden = {l["title"]: l["id"] for l in (board.get("labels") or [])}
|
||||
for name, farbe in DRINGLICHKEIT_FARBE.items():
|
||||
if name not in vorhanden:
|
||||
neu = _api("POST", f"/boards/{board_id}/labels",
|
||||
{"title": name, "color": farbe})
|
||||
vorhanden[name] = neu["id"]
|
||||
return vorhanden
|
||||
|
||||
|
||||
# --- Karte anlegen ------------------------------------------------------------
|
||||
|
||||
def _kartentitel(datensatz: dict) -> str:
|
||||
"""Zeit, Kategorie, Anrufer - in dieser Reihenfolge lesbar auf der Karte."""
|
||||
e = datensatz["auswertung"]
|
||||
wer = e.get("anrufer_name") or datensatz.get("anrufer_nummer") or "unbekannt"
|
||||
zeit = datensatz["empfangen"][11:16]
|
||||
kategorie = KATEGORIE_LABEL.get(e["kategorie"], e["kategorie"])
|
||||
return f"{zeit} · {kategorie} · {wer}"
|
||||
|
||||
|
||||
def karte_anlegen(datensatz: dict, beschreibung: str) -> int | None:
|
||||
"""Legt eine Deck-Karte für den Anruf an. Gibt die Karten-ID zurück.
|
||||
|
||||
Wirft nie — Deck ist ein Zusatz, kein Muss.
|
||||
"""
|
||||
if not (config.nextcloud_aktiv() and config.NEXTCLOUD_DECK):
|
||||
return None
|
||||
try:
|
||||
board_id = _board_id()
|
||||
stapel = _stapel(board_id)
|
||||
labels = _labels(board_id)
|
||||
|
||||
e = datensatz["auswertung"]
|
||||
eingang = config.DECK_STAPEL[0]
|
||||
stack_id = stapel[eingang]
|
||||
karte = _api(
|
||||
"POST", f"/boards/{board_id}/stacks/{stack_id}/cards",
|
||||
{"title": _kartentitel(datensatz), "type": "plain", "order": 0,
|
||||
"description": beschreibung},
|
||||
)
|
||||
karten_id = karte["id"]
|
||||
|
||||
label_id = labels.get(e.get("dringlichkeit"))
|
||||
if label_id is not None:
|
||||
_api("PUT",
|
||||
f"/boards/{board_id}/stacks/{stack_id}/cards/{karten_id}/assignLabel",
|
||||
{"labelId": label_id})
|
||||
return karten_id
|
||||
except Exception as fehler:
|
||||
print(f" → Deck: FEHLGESCHLAGEN ({fehler})")
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue