Logpy-AgentOne/pipe/server.py
Jeuner 1a1280e79d 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.
2026-08-22 22:37:17 +02:00

526 lines
23 KiB
Python

"""Live-Dashboard: zeigt Anrufe, Systemzustand und das laufende Protokoll.
python3 -m pipe.server [--port 8088]
Rein lokal, stdlib. Liest die Ablage, das Ereignisprotokoll und den Zustand der
beteiligten Dienste. Die Seite fragt alle paar Sekunden nach — kein WebSocket,
kein Framework, nichts, was im Praxisbetrieb kaputtgehen kann.
"""
from __future__ import annotations
import argparse
import base64
import hmac
import json
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
from . import config, dashboard, protokoll, stapel, store
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
# damit haeufiges Nachfragen den Rechner nicht belastet.
_CACHE: dict[str, tuple[float, dict]] = {}
CACHE_S = 4.0
def _laeuft(muster: str) -> bool:
try:
return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0
except Exception:
return False
def _http_ok(url: str, timeout: float = 5.0) -> bool:
"""Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud
blockt den Standardnamen von urllib und meldete fälschlich 'nicht
erreichbar'."""
req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()):
return True
except urllib.error.HTTPError:
return True # antwortet - reicht als Lebenszeichen
except Exception:
return False
def _trunk() -> tuple[str, str]:
"""(zustand, text) des SIP-Trunks."""
if not _laeuft("[f]reeswitch"):
return "aus", "FreeSWITCH läuft nicht"
try:
roh = subprocess.run(
["fs_cli", "-P", "8099", "-x", "sofia status gateway plusnet"],
capture_output=True, text=True, timeout=8).stdout
for zeile in roh.splitlines():
if zeile.startswith("Status"):
wert = zeile.split()[-1]
return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}")
return "unklar", "Gateway unbekannt"
except Exception:
return "unklar", "fs_cli nicht erreichbar"
def status() -> dict:
jetzt = time.time()
gepuffert = _CACHE.get("status")
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
return gepuffert[1]
trunk_zustand, trunk_text = _trunk()
watcher = _laeuft("[p]ipe.watch")
ollama = _http_ok(f"{config.OLLAMA_URL}/api/version")
nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php")
if config.nextcloud_aktiv() else None)
offen = 0
if config.TELEFON_EINGANG.is_dir():
offen = sum(1 for d in config.TELEFON_EINGANG.iterdir()
if d.is_file() and not d.name.startswith("."))
fehler = 0
if config.TELEFON_FEHLER.is_dir():
fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file())
daten = {
"dienste": [
{"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text},
{"name": "Verarbeitung", "zustand": "gut" if watcher else "aus",
"text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"},
{"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht",
"text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"},
{"name": "Nextcloud", "zustand": "unklar" if nextcloud is None
else ("gut" if nextcloud else "schlecht"),
"text": "nicht konfiguriert" if nextcloud is None
else ("erreichbar" if nextcloud else "nicht erreichbar")},
],
"eingang_offen": offen,
"fehler": fehler,
"stand": datetime.now().strftime("%H:%M:%S"),
}
_CACHE["status"] = (jetzt, daten)
return daten
def anrufe() -> list[dict]:
liste = []
for d in dashboard._anrufe():
e = d["auswertung"]
zeit = d["empfangen"]
ordner = None
try:
z = datetime.fromisoformat(zeit)
tag = f"{z:%Y-%m-%d}"
for kandidat in (config.ABLAGE_LOKAL / tag).iterdir():
if kandidat.is_dir() and kandidat.name.startswith(f"{z:%H-%M-%S}"):
ordner = f"{tag}/{kandidat.name}"
break
except Exception:
pass
audio = None
if ordner:
for endung in (".wav", ".mp3", ".opus", ".m4a"):
if (config.ABLAGE_LOKAL / ordner / f"aufnahme{endung}").is_file():
audio = f"/audio/{ordner}/aufnahme{endung}"
break
liste.append({
"id": ordner or "",
"stapel": stapel.lies(config.ABLAGE_LOKAL / ordner) if ordner else stapel.erlaubt()[0],
"empfangen": zeit,
"kategorie": e["kategorie"],
"dringlichkeit": e["dringlichkeit"],
"name": e.get("anrufer_name"),
"nummer": d.get("anrufer_nummer") or e.get("rueckrufnummer"),
"rueckruf": bool(e.get("rueckruf_gewuenscht")),
"anliegen": e.get("anliegen_kurz") or "",
"stichworte": e.get("stichworte") or [],
"transkript": d.get("transkript") or "",
"audio": audio,
})
return liste
FS_LOG = Path("/opt/homebrew/var/log/freeswitch/freeswitch.log")
_ZEIT = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
# Was im FreeSWITCH-Log für die Praxis interessant ist - und wie es heißen soll.
_FS_MUSTER = [
("New Channel sofia/external/", "anruf", "Anruf von {nummer}"),
("has been answered", "anruf", "abgenommen, Ansage läuft"),
("Hangup sofia/external/", "anruf", "Anrufer hat aufgelegt"),
("Failed Registration", "fehler", "Telefon: Registrierung fehlgeschlagen"),
("Ping failed", "fehler", "Telefon: Provider antwortet nicht"),
]
def _telefon_ereignisse(anzahl: int = 60) -> list[dict]:
"""Liest Anruf-Ereignisse aus dem FreeSWITCH-Log.
So sieht man den Anruf schon beim Klingeln, nicht erst wenn die fertige
Aufnahme durch die Pipe gelaufen ist.
"""
if not FS_LOG.is_file():
return []
try:
with FS_LOG.open("rb") as f: # nur das Ende lesen
f.seek(0, 2)
f.seek(max(0, f.tell() - 300_000))
zeilen = f.read().decode("utf-8", "replace").splitlines()
except Exception:
return []
ereignisse = []
for zeile in zeilen[-4000:]:
for schnipsel, art, vorlage in _FS_MUSTER:
if schnipsel not in zeile:
continue
# Bei Kanal-Ereignissen steht die Channel-UUID vor dem Zeitstempel,
# die Position ist also nicht verlässlich - daher per Muster suchen.
treffer = _ZEIT.search(zeile)
zeit = treffer.group(0) if treffer else ""
nummer = ""
if "sofia/external/" in zeile:
rest = zeile.split("sofia/external/", 1)[1]
nummer = rest.split("@", 1)[0].strip()
ereignisse.append({
"zeit": zeit.replace(" ", "T"),
"art": art,
"text": vorlage.format(nummer=nummer or "unbekannt"),
})
break
return ereignisse[-anzahl:]
def verlauf(anzahl: int = 150) -> list[dict]:
"""Pipe-Protokoll und Telefonie-Ereignisse, zeitlich zusammengeführt."""
alle = protokoll.lies(anzahl) + _telefon_ereignisse()
alle.sort(key=lambda e: e.get("zeit") or "")
return alle[-anzahl:]
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_): # kein Zugriffslog auf der Konsole
pass
def _angemeldet(self) -> bool:
"""Prüft die Zugangsdaten, sofern eines gesetzt ist.
Ohne Passwort bleibt der Leitstand offen - das ist nur zulässig, wenn er
an 127.0.0.1 hängt; darauf besteht main() beim Start.
"""
if not config.LEITSTAND_PASS:
return True
kopf = self.headers.get("Authorization", "")
if not kopf.startswith("Basic "):
return False
try:
nutzer, _, wort = base64.b64decode(kopf[6:]).decode("utf-8").partition(":")
except Exception:
return False
# compare_digest: Vergleichsdauer verrät nichts über das Passwort.
return (hmac.compare_digest(nutzer, config.LEITSTAND_USER)
and hmac.compare_digest(wort, config.LEITSTAND_PASS))
def _anmeldung_verlangen(self) -> None:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="Anruf-Leitstand"')
self.send_header("Content-Length", "0")
self.end_headers()
def _sende(self, inhalt: bytes, typ: str, code: int = 200) -> None:
self.send_response(code)
self.send_header("Content-Type", typ)
self.send_header("Content-Length", str(len(inhalt)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(inhalt)
def _json(self, daten) -> None:
self._sende(json.dumps(daten, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
def do_GET(self) -> None:
if not self._angemeldet():
self._anmeldung_verlangen()
return
pfad = unquote(urlparse(self.path).path)
if pfad == "/":
self._sende(SEITE.encode("utf-8"), "text/html; charset=utf-8")
elif pfad == "/api/status":
self._json(status())
elif pfad == "/api/anrufe":
self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()})
elif pfad == "/api/verlauf":
self._json(verlauf(150))
elif pfad.startswith("/audio/"):
self._audio(pfad[len("/audio/"):])
else:
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
def do_POST(self) -> None:
if not self._angemeldet():
self._anmeldung_verlangen()
return
if unquote(urlparse(self.path).path) != "/api/stapel":
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
return
try:
laenge = int(self.headers.get("Content-Length") or 0)
if laenge > 10_000:
raise ValueError("Anfrage zu groß")
wunsch = json.loads(self.rfile.read(laenge).decode("utf-8"))
kennung, ziel = wunsch["id"], wunsch["stapel"]
except Exception:
self._json({"ok": False, "fehler": "ungültige Anfrage"})
return
ordner = stapel.ordner_zu(kennung)
if ordner is None:
self._json({"ok": False, "fehler": "Anruf nicht gefunden"})
return
if not stapel.setze(ordner, ziel):
self._json({"ok": False, "fehler": f"unbekannter Stapel: {ziel}"})
return
protokoll.schreibe("stapel", f"{ordner.name}{ziel}")
self._json({"ok": True})
def _audio(self, rest: str) -> None:
"""Liefert eine Aufnahme aus der Ablage - nur von dort."""
ziel = (config.ABLAGE_LOKAL / rest).resolve()
wurzel = config.ABLAGE_LOKAL.resolve()
if not str(ziel).startswith(str(wurzel) + "/") or not ziel.is_file():
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
return
typ = {".wav": "audio/wav", ".mp3": "audio/mpeg",
".opus": "audio/ogg", ".m4a": "audio/mp4"}.get(ziel.suffix, "application/octet-stream")
self._sende(ziel.read_bytes(), typ)
SEITE = r"""<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anruf-Leitstand — Praxis Musterhausen</title>
<style>
:root{--bg:#f4f6f9;--fg:#18202c;--karte:#fff;--spalte:#eceff4;--rand:#dde3ec;--muted:#68748a;
--gut:#1f9d5c;--schlecht:#d93a34;--unklar:#c8901f;--aus:#8a93a3;--log:#f8fafc}
@media(prefers-color-scheme:dark){:root{--bg:#0f1218;--fg:#e6ecf4;--karte:#1b212b;
--spalte:#151a22;--rand:#28303d;--muted:#8d99ad;--log:#10141b}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
.wrap{max-width:1600px;margin:0 auto;padding:18px 16px 30px}
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:12px}
h1{font-size:19px;margin:0}
.stand{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums;margin-left:auto}
.dienste{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:9px}
.dienst{background:var(--karte);border:1px solid var(--rand);border-radius:9px;padding:9px 11px;
display:flex;align-items:center;gap:9px}
.punkt{width:9px;height:9px;border-radius:50%;flex:none}
.gut{background:var(--gut)}.schlecht{background:var(--schlecht)}
.unklar{background:var(--unklar)}.aus{background:var(--aus)}
.dienst b{display:block;font-size:13px}.dienst span{color:var(--muted);font-size:12px}
.hinweis{background:#fdf1e7;border:1px solid #f0c9a0;color:#8a4b12;border-radius:8px;
padding:7px 11px;margin:9px 0;font-size:13px}
@media(prefers-color-scheme:dark){.hinweis{background:#2b1e12;border-color:#5a3a18;color:#f0c08a}}
.board{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px;align-items:start}
@media(max-width:1100px){.board{grid-template-columns:repeat(2,1fr)}}
@media(max-width:660px){.board{grid-template-columns:1fr}}
.spalte{background:var(--spalte);border:1px solid var(--rand);border-radius:12px;padding:10px;
min-height:130px;transition:background .12s,border-color .12s}
.spalte.ueber{border-color:var(--gut);background:color-mix(in srgb,var(--gut) 8%,var(--spalte))}
.spalte h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
margin:0 0 9px;display:flex;gap:7px;align-items:center}
.zahl{background:var(--rand);color:var(--fg);border-radius:999px;padding:0 7px;font-size:11px}
a.waehlen{color:inherit;text-decoration:underline;text-decoration-style:dotted;
text-underline-offset:2px;cursor:pointer}
a.waehlen:hover{color:var(--gut);text-decoration-style:solid}
.karte{background:var(--karte);border:1px solid var(--rand);border-left:4px solid var(--akzent,#888);
border-radius:9px;padding:9px 11px;margin-bottom:9px;cursor:grab}
.karte:active{cursor:grabbing}
.karte.zieht{opacity:.4}
.kopf{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.titel{font-weight:650;font-size:14px}
.dring{color:#fff;font-size:9px;font-weight:700;text-transform:uppercase;padding:2px 6px;
border-radius:999px;letter-spacing:.04em;margin-left:auto}
.meta{color:var(--muted);font-size:12px;margin:3px 0 5px;font-variant-numeric:tabular-nums}
.anliegen{margin:5px 0;font-size:13.5px}
.tag{display:inline-block;background:var(--bg);border:1px solid var(--rand);border-radius:5px;
padding:1px 6px;font-size:11px;color:var(--muted);margin:2px 3px 2px 0}
audio{width:100%;height:32px;margin-top:6px;display:block}
details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:12px}
.tr{color:var(--muted);font-size:12.5px;margin:4px 0 0}
.schieber{display:flex;gap:5px;margin-top:7px;flex-wrap:wrap}
.schieber button{font:inherit;font-size:11px;padding:2px 8px;border-radius:6px;cursor:pointer;
border:1px solid var(--rand);background:var(--bg);color:var(--muted)}
.schieber button:hover{color:var(--fg);border-color:var(--muted)}
.leer{color:var(--muted);font-size:12.5px;padding:5px 2px}
.dienste-kopf{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
margin:22px 0 7px}
#verlauf{background:var(--log);border:1px solid var(--rand);border-radius:11px;padding:10px;
height:180px;overflow-y:auto;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
.z{display:flex;gap:9px;padding:1px 0}
.z time{color:var(--muted);flex:none;font-variant-numeric:tabular-nums}
.z .a{flex:none;width:64px;font-weight:600}
.a-anruf{color:#3b86d8}.a-eingang{color:#1f9d5c}.a-fertig{color:#1f9d5c}
.a-fehler{color:var(--schlecht)}.a-notfall{color:var(--schlecht);font-weight:700}
.a-system{color:var(--muted)}.a-stapel{color:#9b6bd8}
</style></head><body><div class="wrap">
<header><h1>Anruf-Leitstand</h1><span class="stand" id="stand">…</span></header>
<div id="verlauf"></div>
<div id="hinweise"></div>
<div class="board" id="board"></div>
<div class="dienste-kopf">Dienste</div>
<div class="dienste" id="dienste"></div>
</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 esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let STAPEL=[], ANRUFE=[], offeneTranskripte=new Set(), pausiert=false;
async function hole(p){try{const r=await fetch(p);return r.ok?await r.json():null}catch(e){return null}}
async function verschiebe(id,ziel){
// sofort anzeigen, dann speichern - fuehlt sich unmittelbar an
const a=ANRUFE.find(x=>x.id===id); if(!a||a.stapel===ziel)return;
a.stapel=ziel; zeichneBoard();
const r=await fetch('/api/stapel',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:id,stapel:ziel})});
const d=await r.json().catch(()=>null);
if(!d||!d.ok){takt()} // hat nicht geklappt: echten Stand neu holen
}
// Nur Ziffern und ein fuehrendes Plus taugen fuer tel:
const waehlbar=n=>String(n||'').replace(/[^\d+]/g,'').replace(/(?!^)\+/g,'');
function nummerLink(d){
const n=d.nummer, w=waehlbar(n);
if(!w)return '';
return `<a class="waehlen" href="tel:${esc(w)}" data-id="${esc(d.id)}"
title="Rückruf an ${esc(n)} — verschiebt die Karte in ${esc(STAPEL[1]||'')}">${esc(n)}</a>`;
}
function karte(d){
// Notfall zaehlt als Notfall, auch wenn aeltere Datensaetze die Dringlichkeit
// niedriger gesetzt haben.
const f=(d.kategorie==='notfall')?FARBE.notfall:(FARBE[d.dringlichkeit]||'#888');
const i=STAPEL.indexOf(d.stapel);
const knoepfe=STAPEL.map((s,j)=>j===i?'':
`<button data-id="${esc(d.id)}" data-ziel="${esc(s)}">${j<i?'':''} ${esc(s)}</button>`).join('');
const offen=offeneTranskripte.has(d.id)?' open':'';
return `<article class="karte" draggable="true" data-id="${esc(d.id)}" style="--akzent:${f}">
<div class="kopf"><span class="titel">${esc(KAT[d.kategorie]||d.kategorie)} · ${esc(d.name||'unbekannt')}</span>
<span class="dring" style="background:${f}">${esc(d.kategorie==='notfall'?'notfall':d.dringlichkeit)}</span></div>
<div class="meta">${esc(d.empfangen.replace('T',' '))} · ${nummerLink(d)}${d.rueckruf?' · 📞 Rückruf':''}</div>
<p class="anliegen">${esc(d.anliegen)}</p>
<div>${(d.stichworte||[]).map(s=>`<span class="tag">${esc(s)}</span>`).join('')}</div>
${d.audio?`<audio controls preload="none" src="${d.audio}"></audio>`:''}
<details data-id="${esc(d.id)}"${offen}><summary>Transkript</summary><p class="tr">${esc(d.transkript)}</p></details>
<div class="schieber">${knoepfe}</div>
</article>`;
}
function zeichneBoard(){
const el=document.getElementById('board');
el.innerHTML=STAPEL.map(s=>{
const drin=ANRUFE.filter(a=>a.stapel===s);
return `<section class="spalte" data-stapel="${esc(s)}">
<h2>${esc(s)}<span class="zahl">${drin.length}</span></h2>
${drin.map(karte).join('')||'<p class="leer">leer</p>'}
</section>`}).join('');
el.querySelectorAll('.schieber button').forEach(b=>
b.onclick=e=>{e.stopPropagation();verschiebe(b.dataset.id,b.dataset.ziel)});
el.querySelectorAll('a.waehlen').forEach(a=>
a.onclick=()=>{ // der Link waehlt, wir ziehen nur nach
const d=ANRUFE.find(x=>x.id===a.dataset.id);
if(d&&STAPEL[1]&&d.stapel===STAPEL[0])verschiebe(d.id,STAPEL[1]);
});
el.querySelectorAll('details').forEach(d=>{
d.ontoggle=()=>{d.open?offeneTranskripte.add(d.dataset.id):offeneTranskripte.delete(d.dataset.id)};
});
el.querySelectorAll('.karte').forEach(k=>{
k.ondragstart=e=>{pausiert=true;k.classList.add('zieht');e.dataTransfer.setData('text/plain',k.dataset.id)};
k.ondragend=()=>{pausiert=false;k.classList.remove('zieht')};
});
el.querySelectorAll('.spalte').forEach(sp=>{
sp.ondragover=e=>{e.preventDefault();sp.classList.add('ueber')};
sp.ondragleave=()=>sp.classList.remove('ueber');
sp.ondrop=e=>{e.preventDefault();sp.classList.remove('ueber');pausiert=false;
verschiebe(e.dataTransfer.getData('text/plain'),sp.dataset.stapel)};
});
}
function zeichneStatus(s){
if(!s)return;
document.getElementById('stand').textContent='Stand '+s.stand;
document.getElementById('dienste').innerHTML=s.dienste.map(d=>
`<div class="dienst"><span class="punkt ${d.zustand}"></span>
<span><b>${esc(d.name)}</b><span>${esc(d.text)}</span></span></div>`).join('');
const h=[];
// Die Ampeln stehen unten - eine Stoerung muss trotzdem sofort auffallen.
s.dienste.filter(d=>d.zustand==='schlecht'||d.zustand==='aus')
.forEach(d=>h.push(`${d.name}: ${d.text}`));
if(s.eingang_offen>0)h.push(`${s.eingang_offen} Aufnahme(n) warten auf Verarbeitung.`);
if(s.fehler>0)h.push(`${s.fehler} Aufnahme(n) in telefon/fehler — nicht verarbeitet.`);
document.getElementById('hinweise').innerHTML=h.map(t=>`<div class="hinweis">${esc(t)}</div>`).join('');
}
function zeichneVerlauf(v){
if(!v)return;
const el=document.getElementById('verlauf');
const unten=el.scrollHeight-el.scrollTop-el.clientHeight<40;
el.innerHTML=v.map(e=>`<div class="z"><time>${esc((e.zeit||'').slice(11,19))}</time>
<span class="a a-${esc(e.art)}">${esc(e.art)}</span><span>${esc(e.text)}</span></div>`).join('')
||'<p class="leer">Noch nichts passiert.</p>';
if(unten)el.scrollTop=el.scrollHeight;
}
async function takt(){
if(pausiert)return; // nicht neu zeichnen, waehrend jemand zieht
const [s,a,v]=await Promise.all([hole('/api/status'),hole('/api/anrufe'),hole('/api/verlauf')]);
zeichneStatus(s);
if(a){STAPEL=a.stapel;ANRUFE=a.anrufe;zeichneBoard()}
zeichneVerlauf(v);
}
takt();setInterval(takt,3000);
</script></body></html>"""
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)
p.add_argument("--host", default=config.LEITSTAND_HOST,
help="0.0.0.0 macht den Leitstand im Netz erreichbar (dann ist "
"LEITSTAND_PASS Pflicht)")
args = p.parse_args(argv)
oeffentlich = args.host not in {"127.0.0.1", "localhost", "::1"}
if oeffentlich and not config.LEITSTAND_PASS:
print("Abbruch: Der Leitstand soll über das Netz erreichbar sein, aber es ist\n"
"kein LEITSTAND_PASS gesetzt. Auf den Seiten stehen Transkripte und\n"
"Aufnahmen von Patienten — ohne Passwort läge das für jeden im Netz offen.\n"
"Passwort in die .env eintragen und erneut starten.", file=sys.stderr)
return 2
server = ThreadingHTTPServer((args.host, args.port), Handler)
schutz = "mit Passwort" if config.LEITSTAND_PASS else "ohne Passwort (nur lokal)"
print(f"Leitstand: http://{args.host}:{args.port} [{schutz}] — Abbruch mit Strg-C")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nLeitstand beendet.")
return 0
if __name__ == "__main__":
raise SystemExit(main())