diff --git a/.env.example b/.env.example index cf413a0..60d7470 100644 --- a/.env.example +++ b/.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 diff --git a/README.md b/README.md index 2b6ea10..4b61d13 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,54 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. | `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//` | 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//` — 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=` 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 @@ -140,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 ``` @@ -196,7 +236,8 @@ 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 +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. @@ -298,10 +339,10 @@ 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/`) diff --git a/pipe/categorize.py b/pipe/categorize.py index d161060..4763db4 100644 --- a/pipe/categorize.py +++ b/pipe/categorize.py @@ -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//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) diff --git a/pipe/config.py b/pipe/config.py index fb46e4b..7c04eb1 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -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//. +# 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// Ordner +# mit prompt.txt, kategorien.json, sicherheitsnetz.txt, ansage.txt anlegen +# (profile/praxis/ als Vorlage nehmen) und hier PROFIL= 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"))) diff --git a/pipe/dashboard.py b/pipe/dashboard.py index bb7a370..06319f0 100644 --- a/pipe/dashboard.py +++ b/pipe/dashboard.py @@ -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", -} +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""" -Anruf-Übersicht — Praxis Musterhausen +Anruf-Übersicht