mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +02:00
feat: Branchen-Profile - Projekt fuer andere Einsatzzwecke konfigurierbar machen
Kategorien, Prompt und Notfall-Sicherheitsnetz waren fest auf eine Hausarztpraxis zugeschnitten (categorize.py: KATEGORIEN-Liste, _NOTFALL_MUSTER- Regex). Fuer einen anderen Einsatzzweck (z.B. 24h-Aufzug-Notdienst) musste bisher Python-Code geaendert werden. Neu: profile/<name>/ buendelt prompt.txt, kategorien.json, sicherheitsnetz.txt und ansage.txt - alles, was fachlich vom Einsatzzweck abhaengt, an einer Stelle. PROFIL in der .env waehlt den aktiven Ordner (Default "praxis", altes Verhalten bleibt exakt gleich - per Test verifiziert: identische Kategorien- Liste und Sicherheitsnetz-Muster). profile/aufzug-notdienst/ liegt als ausformuliertes Beispielprofil bei (eigene Kategorien, eigenes Sicherheitsnetz, eigene Ansage). Dringlichkeitsstufen (niedrig/normal/hoch/notfall) bleiben bewusst fest - nur die Kategorien sind einsatzzweck-spezifisch. Neues pipe/kategorien.py laedt das Profil-Schema einmal fuer categorize.py, dashboard.py, deck.py und server.py (Leitstand-JS: Kategorie-Label kommen jetzt vom Server statt fest im Frontend zu stehen). prompts/categorize_de.txt und telefon/ansage.txt sind nach profile/praxis/ umgezogen (git erkennt es als Rename). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
This commit is contained in:
parent
d39b360b7b
commit
87621e5bec
17 changed files with 277 additions and 70 deletions
|
|
@ -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
|
||||
|
|
|
|||
65
README.md
65
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/<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
|
||||
|
||||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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"""<!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</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}}}}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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"
|
||||
|
||||
|
|
@ -31,11 +31,7 @@ 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)
|
||||
|
|
|
|||
33
pipe/kategorien.py
Normal file
33
pipe/kategorien.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""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
|
||||
und steckt fest in Farben/Reihenfolge in dashboard.py, deck.py und der
|
||||
Leitstand-Oberfläche - nur die Kategorien selbst unterscheiden sich fachlich.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
from . import config
|
||||
|
||||
DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"]
|
||||
|
||||
|
||||
@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", {})
|
||||
# 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")
|
||||
|
|
@ -16,12 +16,13 @@ import re
|
|||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from . import bewertung, config, dashboard, dienste, export, modellwahl, protokoll, stapel, testzugang
|
||||
from . import bewertung, config, dashboard, dienste, export, kategorien, modellwahl, protokoll, stapel, testzugang
|
||||
|
||||
|
||||
def anrufe() -> list[dict]:
|
||||
|
|
@ -237,7 +238,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return
|
||||
pfad = unquote(urlparse(self.path).path)
|
||||
if pfad == "/":
|
||||
self._sende(SEITE.encode("utf-8"), "text/html; charset=utf-8")
|
||||
self._sende(_seite().encode("utf-8"), "text/html; charset=utf-8")
|
||||
elif pfad == "/api/status":
|
||||
daten = dienste.status()
|
||||
# Testzugang (per "agent-one test"-Link) -> Sicher-Modus im
|
||||
|
|
@ -601,8 +602,7 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:
|
|||
</div>
|
||||
<script>
|
||||
const FARBE={notfall:'#d93a34',hoch:'#e08a2a',normal:'#c8a227',niedrig:'#1f9d5c'};
|
||||
const KAT={termin:'Termin',rezept:'Rezept',ueberweisung:'Überweisung',befund:'Befund',
|
||||
verwaltung:'Verwaltung',beschwerden:'Beschwerden',notfall:'Notfall',rueckruf:'Rückruf',sonstiges:'Sonstiges'};
|
||||
const KAT=__KAT_JSON__;
|
||||
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let STAPEL=[], ANRUFE=[], offeneTranskripte=new Set(), pausiert=false;
|
||||
|
||||
|
|
@ -990,6 +990,17 @@ takt();setInterval(takt,3000);
|
|||
</script></body></html>"""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _seite() -> str:
|
||||
"""SEITE mit den Kategorie-Labels des aktiven Profils (pipe.kategorien).
|
||||
|
||||
Fest pro Prozesslauf (Profil wechselt nur per .env + Neustart, wie die
|
||||
Kategorien selbst) - deshalb einmalig gecacht statt bei jeder Anfrage neu
|
||||
zusammengesetzt.
|
||||
"""
|
||||
return SEITE.replace("__KAT_JSON__", json.dumps(kategorien.KATEGORIE_LABEL, ensure_ascii=False))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Live-Dashboard der Anruf-Pipe.")
|
||||
p.add_argument("--port", type=int, default=config.LEITSTAND_PORT)
|
||||
|
|
|
|||
1
profile/aufzug-notdienst/ansage.txt
Normal file
1
profile/aufzug-notdienst/ansage.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Sie erreichen den 24-Stunden-Notdienst für Aufzugsanlagen der Musterfirma. Befindet sich eine Person eingeschlossen im Aufzug, nennen Sie bitte sofort die genaue Adresse und die Aufzug-Nummer. Bitte hinterlassen Sie danach Ihr Anliegen und Ihre Rückrufnummer nach dem Signal. Wir rufen Sie umgehend zurück.
|
||||
11
profile/aufzug-notdienst/kategorien.json
Normal file
11
profile/aufzug-notdienst/kategorien.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"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
|
||||
18
profile/praxis/kategorien.json
Normal file
18
profile/praxis/kategorien.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
#!/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:
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
# wiederverwendet, es geht dabei keine Patientendaten raus)
|
||||
#
|
||||
# Steuerung ueber Umgebungsvariablen bzw. .env:
|
||||
# PROFIL=<Ordnername unter profile/, Default "praxis">
|
||||
# ANSAGE_TTS=piper|say|mistral
|
||||
# ANSAGE_STIMME=<Piper-Modellname, say-Stimme, oder Voxtral-Stimmen-Slug>
|
||||
# ANSAGE_EMOTION=<Sprecher-ID, nur bei thorsten_emotional: 0=amused,
|
||||
|
|
@ -32,6 +34,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}"
|
||||
|
|
@ -53,7 +59,7 @@ case "$TTS" in
|
|||
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 +67,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 +75,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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue