mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +02:00
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.
162 lines
6 KiB
Python
162 lines
6 KiB
Python
"""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
|