mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +02:00
Bisher stand nur config.OLLAMA_MODELL zur Auswahl - ein neu per "ollama pull" installiertes Modell (z.B. ornith-1.5:9b) tauchte nicht im Leitstand auf, ohne Code-Aenderung. modellwahl.auswahl() fragt jetzt GET /api/tags live ab und baut die Ollama-Optionen daraus; reine Embedding-Modelle (capabilities ohne "completion", z.B. nomic-embed-text) werden ausgefiltert, weil sie nicht kategorisieren koennen. Die festen OpenRouter-Testeintraege bleiben bestehen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
1020 lines
72 KiB
Python
1020 lines
72 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 sys
|
|
import time
|
|
from datetime import datetime
|
|
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
|
|
|
|
|
|
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
|
|
if ordner and stapel.ist_archiviert(config.ABLAGE_LOKAL / ordner):
|
|
continue # archiviert (simuliert) - verschwindet vom Board
|
|
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"),
|
|
"rueckrufnummer": e.get("rueckrufnummer"),
|
|
"email": e.get("email"),
|
|
"email_korrigiert": bool(e.get("email_vermutete_korrektur")),
|
|
"rueckruf": bool(e.get("rueckruf_gewuenscht")),
|
|
"anliegen": e.get("anliegen_kurz") or "",
|
|
"stichworte": e.get("stichworte") or [],
|
|
"transkript": d.get("transkript") or "",
|
|
"audio": audio,
|
|
"kontakt": (d.get("bekannter_kontakt") or {}).get("name"),
|
|
"bewertung": (bewertung.lies(config.ABLAGE_LOKAL / ordner) or {}).get("bewertung") if ordner else None,
|
|
})
|
|
return liste
|
|
|
|
|
|
def archiviere_tag() -> int:
|
|
"""Markiert alle 'Erledigt'-Anrufe als archiviert (simuliert - es wird
|
|
nichts geloescht, nur vom Board geraeumt). Bewertete Anrufe werden dabei
|
|
zusaetzlich als Trainingsdaten exportiert, siehe pipe.export. Gibt die
|
|
Anzahl archivierter Anrufe zurueck."""
|
|
letzter = stapel.erlaubt()[-1]
|
|
anzahl = 0
|
|
for a in anrufe():
|
|
if a["stapel"] != letzter or not a["id"]:
|
|
continue
|
|
ordner = stapel.ordner_zu(a["id"])
|
|
if ordner and stapel.archiviere(ordner):
|
|
anzahl += 1
|
|
return anzahl
|
|
|
|
|
|
FS_LOG = config.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:]
|
|
|
|
|
|
# Bei Testzugang (kein Passwort, oeffentlicher Link) werden Rufnummern nicht
|
|
# nur im Frontend per CSS geblurred (das verdeckt nur die Anzeige - im
|
|
# Seitenquelltext/DevTools/Netzwerk-Tab stehen sie trotzdem echt drin),
|
|
# sondern schon hier serverseitig ersetzt. Die echten Werte verlassen den
|
|
# Server in diesem Modus gar nicht erst.
|
|
_MASKE = "•••• •••• •••"
|
|
|
|
|
|
def _maskiere_anrufe(anrufe_liste: list[dict]) -> list[dict]:
|
|
for a in anrufe_liste:
|
|
if a.get("nummer"):
|
|
a["nummer"] = _MASKE
|
|
if a.get("rueckrufnummer"):
|
|
a["rueckrufnummer"] = _MASKE
|
|
return anrufe_liste
|
|
|
|
|
|
def _maskiere_verlauf(eintraege: list[dict]) -> list[dict]:
|
|
for e in eintraege:
|
|
if e.get("text"):
|
|
e["text"] = re.sub(r"\d{9,}", "•••••••••", e["text"])
|
|
return eintraege
|
|
|
|
|
|
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.
|
|
|
|
Zusätzlich akzeptiert: ein gültiges signiertes Testzugangs-Token
|
|
(siehe pipe.testzugang) - per ?zugang=... in der URL (so kommt es aus
|
|
dilles-agent.php's "agent-one test"-Kommando) oder per zugang-Cookie
|
|
für Folgeanfragen (fetch()-Aufrufe der Seite tragen die URL nicht
|
|
mit). Ein frisches URL-Token setzt das Cookie für den Rest der Stunde.
|
|
"""
|
|
self._zusatz_header: list[tuple[str, str]] = []
|
|
self._via_token = False # True nur bei Zugang per Testzugangs-Token, nicht Passwort
|
|
if not config.LEITSTAND_PASS:
|
|
return True
|
|
|
|
token = parse_qs(urlparse(self.path).query).get("zugang", [None])[0]
|
|
if token and testzugang.gueltig(token):
|
|
rest_s = max(0, int(token.split(".", 1)[0]) - int(time.time()))
|
|
self._zusatz_header.append(
|
|
("Set-Cookie", f"zugang={token}; Max-Age={rest_s}; Path=/; HttpOnly; Secure; SameSite=Lax"))
|
|
self._via_token = True
|
|
return True
|
|
cookie_wert = SimpleCookie(self.headers.get("Cookie", "")).get("zugang")
|
|
if cookie_wert and testzugang.gueltig(cookie_wert.value):
|
|
self._via_token = True
|
|
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")
|
|
for schluessel, wert in getattr(self, "_zusatz_header", []):
|
|
self.send_header(schluessel, wert)
|
|
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":
|
|
daten = dienste.status()
|
|
# Testzugang (per "agent-one test"-Link) -> Sicher-Modus im
|
|
# Frontend erzwingen, siehe sicherSetzen() im JS.
|
|
daten["testzugang"] = getattr(self, "_via_token", False)
|
|
self._json(daten)
|
|
elif pfad == "/api/anrufe":
|
|
liste = anrufe()
|
|
if getattr(self, "_via_token", False):
|
|
liste = _maskiere_anrufe(liste)
|
|
self._json({"stapel": stapel.erlaubt(), "anrufe": liste})
|
|
elif pfad == "/api/verlauf":
|
|
eintraege = verlauf(150)
|
|
if getattr(self, "_via_token", False):
|
|
eintraege = _maskiere_verlauf(eintraege)
|
|
self._json(eintraege)
|
|
elif pfad == "/api/modell":
|
|
self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.auswahl()})
|
|
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
|
|
pfad = unquote(urlparse(self.path).path)
|
|
if pfad == "/api/archivieren":
|
|
anzahl = archiviere_tag()
|
|
protokoll.schreibe("archiv", f"{anzahl} Anruf(e) archiviert (simuliert)", anzahl=anzahl)
|
|
_, export_anzahl = export.exportiere()
|
|
if export_anzahl:
|
|
protokoll.schreibe("export", f"{export_anzahl} bewertete Anrufe exportiert")
|
|
# Verlauf-Log sichern + leeren, fuer eine frische Ansicht ab jetzt.
|
|
# audit.log (dauerhaft, unveraenderlich) ist davon nicht betroffen.
|
|
log_archiv = protokoll.archiviere_und_leere()
|
|
if log_archiv:
|
|
protokoll.schreibe("system", f"Verlauf archiviert nach {log_archiv.name} und geleert")
|
|
self._json({"ok": True, "anzahl": anzahl, "simuliert": True, "export_anzahl": export_anzahl,
|
|
"log_archiviert": log_archiv.name if log_archiv else None})
|
|
return
|
|
if pfad == "/api/export":
|
|
ziel, anzahl = export.exportiere()
|
|
protokoll.schreibe("export", f"{anzahl} bewertete Anrufe exportiert nach {ziel.name}")
|
|
self._json({"ok": True, "anzahl": anzahl, "datei": ziel.name})
|
|
return
|
|
if pfad == "/api/bewertung":
|
|
try:
|
|
laenge = int(self.headers.get("Content-Length") or 0)
|
|
wunsch = json.loads(self.rfile.read(min(laenge, 10_000)).decode("utf-8"))
|
|
kennung, wert = wunsch["id"], wunsch["bewertung"]
|
|
korrektur = wunsch.get("korrektur")
|
|
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 bewertung.setze(ordner, wert, korrektur):
|
|
self._json({"ok": False, "fehler": f"ungültige Bewertung: {wert}"})
|
|
return
|
|
protokoll.schreibe("bewertung", f"{ordner.name} → {wert}"
|
|
+ (f" (Korrektur: {korrektur})" if korrektur else ""))
|
|
self._json({"ok": True})
|
|
return
|
|
if pfad == "/api/modell":
|
|
try:
|
|
laenge = int(self.headers.get("Content-Length") or 0)
|
|
kennung = json.loads(self.rfile.read(min(laenge, 10_000)).decode("utf-8"))["id"]
|
|
except Exception:
|
|
self._json({"ok": False, "fehler": "ungültige Anfrage"})
|
|
return
|
|
if not modellwahl.setze(kennung):
|
|
self._json({"ok": False, "fehler": f"unbekanntes Modell: {kennung}"})
|
|
return
|
|
protokoll.schreibe("system", f"Kategorisierungs-Modell → {kennung}")
|
|
self._json({"ok": True})
|
|
return
|
|
if pfad != "/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.
|
|
|
|
Muss HTTP-Range unterstuetzen: der <audio>-Player im Leitstand schickt
|
|
beim Laden erst eine kleine Range-Anfrage (z.B. die ersten paar KB),
|
|
um Dauer/Seekbarkeit zu ermitteln. Antwortet der Server darauf mit
|
|
200 + der kompletten Datei statt 206 + dem angefragten Ausschnitt,
|
|
haelt der Browser sich an die urspruenglich angefragte (kleine)
|
|
Groesse und bricht die Wiedergabe nach ein paar Sekunden ab, obwohl
|
|
die Datei komplett uebertragen wurde.
|
|
"""
|
|
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")
|
|
groesse = ziel.stat().st_size
|
|
bereich = self.headers.get("Range")
|
|
if not bereich:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", typ)
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header("Content-Length", str(groesse))
|
|
self.send_header("Cache-Control", "no-store")
|
|
for schluessel, wert in getattr(self, "_zusatz_header", []):
|
|
self.send_header(schluessel, wert)
|
|
self.end_headers()
|
|
with ziel.open("rb") as datei:
|
|
self.wfile.write(datei.read())
|
|
return
|
|
|
|
treffer = re.match(r"bytes=(\d*)-(\d*)", bereich)
|
|
start = int(treffer.group(1)) if treffer and treffer.group(1) else 0
|
|
ende = int(treffer.group(2)) if treffer and treffer.group(2) else groesse - 1
|
|
ende = min(ende, groesse - 1)
|
|
if not treffer or start > ende or start >= groesse:
|
|
self.send_response(416)
|
|
self.send_header("Content-Range", f"bytes */{groesse}")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
|
|
laenge = ende - start + 1
|
|
self.send_response(206)
|
|
self.send_header("Content-Type", typ)
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header("Content-Range", f"bytes {start}-{ende}/{groesse}")
|
|
self.send_header("Content-Length", str(laenge))
|
|
self.send_header("Cache-Control", "no-store")
|
|
for schluessel, wert in getattr(self, "_zusatz_header", []):
|
|
self.send_header(schluessel, wert)
|
|
self.end_headers()
|
|
with ziel.open("rb") as datei:
|
|
datei.seek(start)
|
|
self.wfile.write(datei.read(laenge))
|
|
|
|
|
|
SEITE = r"""<!doctype html><html lang="de"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Logpy:AgentOne — Anruf-Leitstand</title>
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%98%8E%EF%B8%8F%3C/text%3E%3C/svg%3E">
|
|
<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:center;gap:12px;flex-wrap:wrap;margin-bottom:12px}
|
|
.titelblock{display:flex;flex-direction:column;gap:1px}
|
|
h1{font-size:19px;margin:0;line-height:1.25}
|
|
.untertitel{font-size:12px;color:var(--muted);font-weight:400}
|
|
.logo{width:34px;height:34px;border-radius:50%;flex:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}
|
|
.stand{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums;margin-left:auto}
|
|
.modell-select{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
|
|
padding:6px 10px;font-size:13px;font-family:inherit;cursor:pointer;max-width:280px}
|
|
.verbindung-banner{display:none;align-items:center;gap:9px;background:var(--unklar);color:#1a1500;
|
|
font-weight:700;font-size:14px;padding:9px 14px;border-radius:9px;margin:9px 0}
|
|
.verbindung-banner.zeigen{display:flex}
|
|
.fuss{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:26px;padding-top:14px;
|
|
border-top:1px solid var(--rand);color:var(--muted);font-size:12.5px}
|
|
.fuss a{display:inline-flex;align-items:center;gap:5px;color:var(--muted);text-decoration:none}
|
|
.fuss a:hover{color:var(--fg);text-decoration:underline}
|
|
.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}}
|
|
.notfall-banner{display:none;align-items:center;gap:9px;background:var(--schlecht);color:#fff;
|
|
font-weight:700;font-size:14px;padding:9px 14px;border-radius:9px;margin:9px 0;
|
|
animation:notfallBlinken 1.6s ease-in-out infinite}
|
|
.notfall-banner.zeigen{display:flex}
|
|
@keyframes notfallBlinken{0%,100%{opacity:1}50%{opacity:.6}}
|
|
@keyframes notfallRahmen{0%,100%{box-shadow:0 0 0 0 rgba(233,50,45,.6)}
|
|
50%{box-shadow:0 0 0 9px rgba(233,50,45,0)}}
|
|
.karte.notfall-offen{animation:notfallRahmen 1.6s ease-in-out infinite}
|
|
.archiv-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
|
|
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit}
|
|
.archiv-btn:hover{border-color:var(--muted)}
|
|
.archiv-btn:disabled{opacity:.5;cursor:default}
|
|
.export-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
|
|
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit;margin-left:8px}
|
|
.export-btn:hover{border-color:var(--muted)}
|
|
.melde-btn{font-size:13px;background:var(--bg);border:1px solid var(--rand);border-radius:7px;
|
|
padding:2px 6px;cursor:pointer;line-height:1.4;opacity:.5;flex:none}
|
|
.melde-btn:hover{opacity:1;border-color:var(--muted)}
|
|
.melde-btn.aktiv{opacity:1;background:color-mix(in srgb,var(--schlecht) 18%,var(--bg));border-color:var(--schlecht)}
|
|
/* Meldung-Modal: generisches, wiederverwendbares Overlay (siehe JS-Objekt
|
|
"meldung" unten) - nicht fest an den Melde-Button gekoppelt, damit
|
|
spaetere "Melden"-Stellen dasselbe Modal nutzen koennen. */
|
|
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);
|
|
z-index:20;align-items:center;justify-content:center;padding:20px}
|
|
.modal-overlay.zeigen{display:flex}
|
|
.modal{background:var(--karte);border-radius:12px;padding:18px;max-width:420px;width:100%;
|
|
box-shadow:0 12px 40px rgba(0,0,0,.3)}
|
|
.modal h3{margin:0 0 4px;font-size:15px}
|
|
.modal-kontext{color:var(--muted);font-size:12.5px;margin:0 0 10px}
|
|
.modal-auswahl{display:flex;gap:10px;margin-bottom:10px}
|
|
.modal-auswahl label{flex:1;display:flex;flex-direction:column;gap:3px;font-size:11.5px;color:var(--muted)}
|
|
.modal-auswahl select{font:inherit;font-size:13px;padding:5px 6px;border-radius:7px;
|
|
border:1px solid var(--rand);background:var(--bg);color:var(--fg)}
|
|
.modal textarea{width:100%;min-height:90px;font:inherit;font-size:13px;padding:8px;
|
|
border-radius:8px;border:1px solid var(--rand);background:var(--bg);color:var(--fg);resize:vertical}
|
|
.modal-aktionen{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}
|
|
.modal-aktionen button{font:inherit;font-size:13px;padding:6px 14px;border-radius:8px;cursor:pointer;
|
|
border:1px solid var(--rand);background:var(--bg);color:var(--fg)}
|
|
.modal-aktionen button.primaer{background:var(--gut);border-color:var(--gut);color:#fff}
|
|
.sicher-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
|
|
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit;margin-right:8px}
|
|
.sicher-btn:hover{border-color:var(--muted)}
|
|
.sicher-btn.aktiv{background:var(--gut);border-color:var(--gut);color:#fff}
|
|
/* Sicher-Modus: Patientendaten unscharf, fuer Bildschirmaufnahmen/Screenshots.
|
|
Struktur (Kategorie, Dringlichkeit, Zeit, Stapel) bleibt lesbar - nur was
|
|
auf eine Person zurueckfuehrt wird geblurrt. */
|
|
body.sicher a.waehlen{filter:blur(6px);user-select:none}
|
|
body.sicher .tel-blur{filter:blur(6px);user-select:none}
|
|
.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,12px);background:var(--fg);
|
|
color:var(--bg);padding:9px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;
|
|
transition:opacity .2s,transform .2s;z-index:10;box-shadow:0 4px 16px rgba(0,0,0,.2)}
|
|
.toast.zeigen{opacity:1;transform:translate(-50%,0)}
|
|
|
|
.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}
|
|
a.mail{color:inherit;text-decoration:underline;text-decoration-style:dotted;
|
|
text-underline-offset:2px;cursor:pointer;word-break:break-all}
|
|
a.mail: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:14.5px;margin-top:4px;line-height:1.3}
|
|
.hinweis-klein{color:var(--muted);font-size:11px;font-weight:400;font-style:italic;white-space:nowrap}
|
|
.warn-klein{color:var(--unklar);font-size:11px;font-weight:600;white-space:nowrap}
|
|
.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;
|
|
scrollbar-width:thin;scrollbar-color:var(--muted) transparent}
|
|
#verlauf::-webkit-scrollbar{width:8px}
|
|
#verlauf::-webkit-scrollbar-track{background:transparent}
|
|
#verlauf::-webkit-scrollbar-thumb{background:var(--muted);border-radius:99px;border:2px solid var(--log)}
|
|
#verlauf::-webkit-scrollbar-thumb:hover{background:var(--fg)}
|
|
.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}.a-archiv{color:#9b6bd8}
|
|
.a-kontakt{color:var(--gut)}
|
|
.kontakt-badge{display:inline-block;background:color-mix(in srgb,var(--gut) 16%,transparent);
|
|
color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;margin-left:6px}
|
|
</style></head><body><div class="wrap">
|
|
<header><img class="logo" src="data:image/png;base64,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" alt=""><div class="titelblock"><h1>Logpy:AgentOne</h1><span class="untertitel">Anruf-Leitstand</span></div>
|
|
<button class="sicher-btn" id="sicherBtn" title="Namen, Nummern, Anliegen und Transkripte unscharf - fuer Bildschirmaufnahmen/Screenshots">🙈 Sicher-Modus</button>
|
|
<button class="archiv-btn" id="archivBtn" title="Erledigte Anrufe vom Board raeumen (simuliert, nichts wird geloescht) - bewertete Anrufe werden als Trainingsdaten exportiert, das Verlauf-Log wird gesichert und geleert">🗄️ Tag archivieren</button>
|
|
<button class="export-btn" id="exportBtn" title="Bewertete Anrufe (👍/👎) als JSONL-Trainingsdaten exportieren, unabhaengig vom Archivieren">📤 Bewertungen exportieren</button>
|
|
<span class="stand" id="stand">…</span>
|
|
<select class="modell-select" id="modellSelect" title="Kategorisierungs-Modell fuer neue Anrufe - Aenderung greift sofort, kein Neustart noetig"></select>
|
|
</header>
|
|
<div class="dienste-kopf">Dienste</div>
|
|
<div class="dienste" id="dienste"></div>
|
|
<div id="verlauf"></div>
|
|
<div class="verbindung-banner" id="verbindungBanner">📡 <span id="verbindungText"></span></div>
|
|
<div id="hinweise"></div>
|
|
<div class="notfall-banner" id="notfallBanner">🚨 <span id="notfallText"></span></div>
|
|
<div class="board" id="board"></div>
|
|
<footer class="fuss">
|
|
<span>Logpy:AgentOne von Dilles</span>
|
|
<a href="https://github.com/Jeuners/fonagent-one" target="_blank" rel="noopener">
|
|
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38
|
|
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58
|
|
1.32.82.77 1.3 2.01.93 2.5.71.08-.55.3-.93.55-1.14-1.9-.22-3.9-.95-3.9-4.22 0-.93.33-1.7.87-2.29-.09-.22-.38-1.09.08-2.27
|
|
0 0 .71-.23 2.34.87.68-.19 1.4-.29 2.12-.29.72 0 1.44.1 2.12.29 1.63-1.1 2.34-.87 2.34-.87.46 1.18.17 2.05.08 2.27.54.59.87
|
|
1.36.87 2.29 0 3.28-2 4-3.9 4.22.31.27.58.79.58 1.6 0 1.15-.01 2.08-.01 2.36 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
Projekt auf GitHub
|
|
</a>
|
|
<a href="https://www.linkedin.com/in/hgod/" target="_blank" rel="noopener">
|
|
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true"><path fill="currentColor" d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.03-1.85-3.03-1.85
|
|
0-2.14 1.45-2.14 2.94v5.66H9.34V9h3.41v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34
|
|
7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z"/></svg>
|
|
LinkedIn
|
|
</a>
|
|
<a href="tel:+4915223062462">📞 +49 152 23062462</a>
|
|
<a href="https://next.dillenberg.net/u/dillenberg" target="_blank" rel="noopener">☁️ Nextcloud</a>
|
|
<a href="https://paypal.me/jeuner" target="_blank" rel="noopener">☕ Unterstützen</a>
|
|
</footer>
|
|
</div>
|
|
<div class="toast" id="toast"></div>
|
|
<div class="modal-overlay" id="meldungOverlay">
|
|
<div class="modal">
|
|
<h3 id="meldungTitel">Meldung</h3>
|
|
<p class="modal-kontext" id="meldungKontext"></p>
|
|
<div class="modal-auswahl">
|
|
<label>Kategorie<select id="meldungKategorie"></select></label>
|
|
<label>Status<select id="meldungStatus"></select></label>
|
|
</div>
|
|
<textarea id="meldungTextfeld" placeholder="Was ist hier nicht richtig?"></textarea>
|
|
<div class="modal-aktionen">
|
|
<button id="meldungAbbrechen">Abbrechen</button>
|
|
<button id="meldungSpeichern" class="primaer">Speichern</button>
|
|
</div>
|
|
</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=>({'&':'&','<':'<','>':'>','"':'"'}[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
|
|
}
|
|
|
|
// Bewertung dient gleichzeitig als Trainingsdaten (siehe pipe.export) - 👍/👎
|
|
// pro Anruf, bei 👎 optional die richtige Kategorie/Dringlichkeit dazu.
|
|
async function bewerte(id,wert,korrektur){
|
|
const a=ANRUFE.find(x=>x.id===id); if(a)a.bewertung=wert;
|
|
const r=await fetch('/api/bewertung',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({id,bewertung:wert,korrektur:korrektur||null})});
|
|
const d=await r.json().catch(()=>null);
|
|
toast(d&&d.ok?'Meldung gespeichert':'Meldung fehlgeschlagen');
|
|
if(a)zeichneBoard();
|
|
}
|
|
|
|
// 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':'';
|
|
// Notfall, solange nicht im letzten Stapel (= erledigt) - pulsiert als
|
|
// Blickfang, siehe pruefeNotfall() fuer den zugehoerigen Minuten-Ton.
|
|
const notfallOffen=d.dringlichkeit==='notfall'&&d.stapel!==STAPEL[STAPEL.length-1];
|
|
// Name kommt vom LLM aus dem Transkript (Vermutung, kein Abgleich) - im
|
|
// Unterschied zum kontakt-badge (echter Rufnummer-Abgleich gegen das
|
|
// Adressbuch). Redesign: statt 3x einer eigenen "ungeprüft"-Chip (Name,
|
|
// Rueckrufnummer, E-Mail einzeln - sah zugemuellt aus) jetzt ein einziger,
|
|
// dezenter Hinweis (kein Rahmen/Chip, nur kleiner Text) direkt am Namen,
|
|
// plus Rueckrufnummer+E-Mail auf einer gemeinsamen Zeile mit nur einem
|
|
// gemeinsamen Hinweis am Ende statt Einzel-Badges pro Feld.
|
|
const quelleTag=d.name?`<span class="hinweis-klein" title="Name aus dem Sprach-Transkript erkannt (Ollama) - nicht verifiziert">🎙️ ungeprüft</span>`:'';
|
|
// Genannte Rueckrufnummer nur zeigen, wenn sie sich von der CLIP-Nummer
|
|
// unterscheidet - sonst reine Wiederholung. Genau der Fall (Anruf von
|
|
// fremdem Anschluss, Rueckruf auf eigener Nummer gewuenscht) ging bisher
|
|
// im UI unter, obwohl er extrahiert wird (auswertung.rueckrufnummer).
|
|
const abweichend=d.rueckrufnummer&&waehlbar(d.rueckrufnummer)!==waehlbar(d.nummer);
|
|
// email_korrigiert: Whisper transkribiert gesprochenes "at" beim Diktieren
|
|
// manchmal als Punkt statt "@" (z.B. "gdg.dillenberg.net" statt
|
|
// "gdg@dillenberg.net") - categorize.py repariert das als Vorschlag, aber
|
|
// mehrdeutig (koennte auch eine genannte Subdomain sein), deshalb sichtbar
|
|
// als Vermutung markiert statt stillschweigend uebernommen - das ist ein
|
|
// eigener, staerkerer Warnhinweis (Daten evtl. FALSCH, nicht nur ungeprueft).
|
|
const kontaktteile=[];
|
|
if(abweichend)kontaktteile.push(`☎️ ${nummerLink({...d,nummer:d.rueckrufnummer})}`);
|
|
if(d.email)kontaktteile.push(`📧 <a class="mail" href="mailto:${esc(d.email)}">${esc(d.email)}</a>`
|
|
+(d.email_korrigiert?` <span class="warn-klein" title="Whisper hat das @ vermutlich als Punkt transkribiert - automatisch repariert, bitte prüfen">⚠️ vermutete Korrektur</span>`:''));
|
|
const kontaktZeile=kontaktteile.length?`<div class="meta">${kontaktteile.join(' · ')}
|
|
<span class="hinweis-klein" title="Aus dem Sprach-Transkript erkannt - nicht verifiziert">🎙️ ungeprüft</span></div>`:'';
|
|
return `<article class="karte${notfallOffen?' notfall-offen':''}" draggable="true" data-id="${esc(d.id)}" style="--akzent:${f}">
|
|
<div class="kopf">
|
|
<span class="dring" style="background:${f}">${esc(d.kategorie==='notfall'?'notfall':d.dringlichkeit)}</span>
|
|
<button class="melde-btn${d.bewertung==='schlecht'?' aktiv':''}" data-id="${esc(d.id)}"
|
|
title="Kategorisierung melden - Feedback für die Entwicklung">👎</button>
|
|
</div>
|
|
<div class="titel">${esc(KAT[d.kategorie]||d.kategorie)} · ${esc(d.name||'unbekannt')} ${quelleTag}</div>
|
|
<div class="meta">${esc(d.empfangen.replace('T',' '))} · ${nummerLink(d)}${d.rueckruf?' · 📞 Rückruf':''}${d.kontakt?`<span class="kontakt-badge" title="Name via Rufnummer-Abgleich gegen das Adressbuch bestaetigt">✓ ${esc(d.kontakt)}</span>`:''}</div>
|
|
${kontaktZeile}
|
|
<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('.melde-btn').forEach(btn=>{
|
|
btn.onclick=e=>{
|
|
e.stopPropagation();
|
|
const id=btn.dataset.id, d=ANRUFE.find(x=>x.id===id);
|
|
meldung.oeffnen({
|
|
titel:'Kategorisierung melden',
|
|
kontext:d?`aktuell: ${KAT[d.kategorie]||d.kategorie} · ${d.dringlichkeit} · ${d.name||'unbekannt'}`:'',
|
|
kategorie:d&&d.kategorie, dringlichkeit:d&&d.dringlichkeit,
|
|
aufSpeichern:daten=>bewerte(id,'schlecht',daten),
|
|
});
|
|
};
|
|
});
|
|
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)};
|
|
});
|
|
}
|
|
|
|
// Notfall-Alarm: solange mindestens ein Notfall offen ist (nicht im letzten
|
|
// Stapel), pulsiert die Karte (CSS) und alle 60s ein doppelter Piepton -
|
|
// zusaetzlich sofort einmal, wenn ein Notfall neu auftaucht. Browser
|
|
// blockieren Audio ohne vorherigen Klick auf die Seite (Autoplay-Policy) -
|
|
// deshalb der einmalige 'click'-Listener unten, der den AudioContext oeffnet.
|
|
let audioCtx=null, notfallOffenVorher=false;
|
|
function audioFreischalten(){
|
|
try{audioCtx=audioCtx||new (window.AudioContext||window.webkitAudioContext)()}catch(e){}
|
|
}
|
|
document.addEventListener('click',audioFreischalten,{once:true});
|
|
|
|
function piepton(){
|
|
if(!audioCtx)return; // noch nicht freigeschaltet - naechster Klick holt es nach
|
|
const einzelton=(zeit,frequenz)=>{
|
|
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
|
|
o.type='square'; o.frequency.value=frequenz;
|
|
o.connect(g); g.connect(audioCtx.destination);
|
|
g.gain.setValueAtTime(0.0001,zeit);
|
|
g.gain.exponentialRampToValueAtTime(0.28,zeit+0.02);
|
|
g.gain.exponentialRampToValueAtTime(0.0001,zeit+0.45);
|
|
o.start(zeit); o.stop(zeit+0.46);
|
|
};
|
|
const jetzt=audioCtx.currentTime;
|
|
einzelton(jetzt,880);
|
|
einzelton(jetzt+0.3,1100);
|
|
}
|
|
|
|
function pruefeNotfall(){
|
|
const letzterStapel=STAPEL[STAPEL.length-1];
|
|
const offen=ANRUFE.filter(a=>a.dringlichkeit==='notfall'&&a.stapel!==letzterStapel);
|
|
const banner=document.getElementById('notfallBanner');
|
|
banner.classList.toggle('zeigen',offen.length>0);
|
|
if(offen.length){
|
|
document.getElementById('notfallText').textContent=offen.length===1
|
|
?'1 offene Notfall-Meldung'
|
|
:`${offen.length} offene Notfall-Meldungen`;
|
|
if(!notfallOffenVorher)piepton(); // sofort beim erstmaligen Auftreten
|
|
}
|
|
notfallOffenVorher=offen.length>0;
|
|
}
|
|
setInterval(()=>{if(notfallOffenVorher)piepton()},60000);
|
|
|
|
function zeichneStatus(s){
|
|
if(!s)return;
|
|
// Testzugang (per "agent-one test"-Link, kein Passwort) -> Sicher-Modus
|
|
// erzwingen, nicht abschaltbar. Nur einmal reagieren, wenn der Status das
|
|
// erste Mal als Testzugang markiert ankommt.
|
|
if(s.testzugang&&!sicherErzwungen){
|
|
sicherErzwungen=true;
|
|
sicherSetzen(true);
|
|
}
|
|
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('');
|
|
}
|
|
|
|
// Log-Zeilen enthalten oft die Anruf-ID (z.B. "23-44-01_004915223062462" -
|
|
// Zeitstempel + Rufnummer zusammen, aus ordner.name). Nur die Ziffernfolge
|
|
// ab 9 Stellen blurren (Rufnummern), nicht Datum/Uhrzeit-Anteile (<=8 Ziffern
|
|
// in diesem Format) und nicht den restlichen Log-Text.
|
|
const telBlur=s=>s.replace(/\d{9,}/g,m=>`<span class="tel-blur">${m}</span>`);
|
|
|
|
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>${telBlur(esc(e.text))}</span></div>`).join('')
|
|
||'<p class="leer">Noch nichts passiert.</p>';
|
|
if(unten)el.scrollTop=el.scrollHeight;
|
|
}
|
|
|
|
// Verbindungsverlust sichtbar machen statt still zu verharren: hole()
|
|
// schluckt Netzwerkfehler und gibt nur null zurueck - ohne dieses Tracking
|
|
// sah man dem Board nicht an, dass es seit X Minuten nicht mehr aktualisiert
|
|
// wurde. Erst ab 2 aufeinanderfolgenden Fehlversuchen zeigen (~6s), damit ein
|
|
// einzelner kurzer Netz-Hakler nicht sofort Alarm ausloest.
|
|
let verbindungsFehler=0, letzteAktualisierung=null;
|
|
function zeichneVerbindung(){
|
|
const banner=document.getElementById('verbindungBanner');
|
|
banner.classList.toggle('zeigen',verbindungsFehler>=2);
|
|
if(verbindungsFehler>=2){
|
|
const seit=letzteAktualisierung?letzteAktualisierung.toLocaleTimeString('de-DE'):'nie';
|
|
document.getElementById('verbindungText').textContent=
|
|
`Keine Verbindung zum Server — zuletzt aktualisiert: ${seit}`;
|
|
}
|
|
}
|
|
|
|
async function takt(){
|
|
if(pausiert)return; // nicht neu zeichnen, waehrend jemand zieht
|
|
// Auch nicht neu zeichnen, waehrend eine Aufnahme laeuft - zeichneBoard()
|
|
// ersetzt die Karten per innerHTML und reisst damit jedes spielende
|
|
// <audio>-Element mitten in der Wiedergabe weg (Abbruch nach <3s).
|
|
// BUGFIX: [paused] ist ein HTML-Attribut, das <audio> nie gesetzt hat - der
|
|
// Abspielzustand ist eine reine JS/DOM-Eigenschaft (.paused). Der Selektor
|
|
// "audio:not([paused])" traf deshalb auf JEDES Audio-Element zu, egal ob
|
|
// etwas lief - das Board hat sich praktisch nie mehr neu gezeichnet, sobald
|
|
// irgendeine Karte eine Aufnahme hatte (also fast immer).
|
|
if([...document.querySelectorAll('#board audio')].some(a=>!a.paused))return;
|
|
// Gleiches Problem wie beim Audio-Player: zeichneBoard() baut die Karten
|
|
// per innerHTML komplett neu - waehrend das Meldung-Modal offen ist, nicht
|
|
// neu zeichnen (sonst reisst es die Karte darunter samt Eingabe weg).
|
|
if(document.getElementById('meldungOverlay').classList.contains('zeigen'))return;
|
|
const [s,a,v]=await Promise.all([hole('/api/status'),hole('/api/anrufe'),hole('/api/verlauf')]);
|
|
if(s===null&&a===null&&v===null){verbindungsFehler++}
|
|
else{verbindungsFehler=0;letzteAktualisierung=new Date()}
|
|
zeichneVerbindung();
|
|
zeichneStatus(s);
|
|
if(a){STAPEL=a.stapel;ANRUFE=a.anrufe;zeichneBoard();pruefeNotfall()}
|
|
zeichneVerlauf(v);
|
|
}
|
|
let toastZeit=null;
|
|
function toast(text){
|
|
const el=document.getElementById('toast');
|
|
el.textContent=text; el.classList.add('zeigen');
|
|
clearTimeout(toastZeit);
|
|
toastZeit=setTimeout(()=>el.classList.remove('zeigen'),3500);
|
|
}
|
|
|
|
// Kleines, generisches Modal-"Framework" fuer Freitext-Meldungen - nicht
|
|
// fest an den Melde-Button auf der Karte gekoppelt. Aufruf ueberall im Code:
|
|
// meldung.oeffnen({titel, kontext, aufSpeichern: text => {...}})
|
|
// aufSpeichern wird erst nach Klick auf "Speichern" mit dem eingegebenen
|
|
// Text aufgerufen; leere Eingaben werden abgewiesen (Fokus bleibt im Feld).
|
|
const DRINGLICHKEITEN=['niedrig','normal','hoch','notfall'];
|
|
const meldung=(()=>{
|
|
const overlay=document.getElementById('meldungOverlay');
|
|
const titelEl=document.getElementById('meldungTitel');
|
|
const kontextEl=document.getElementById('meldungKontext');
|
|
const auswahlEl=document.querySelector('.modal-auswahl');
|
|
const katSel=document.getElementById('meldungKategorie');
|
|
const statusSel=document.getElementById('meldungStatus');
|
|
const textEl=document.getElementById('meldungTextfeld');
|
|
let aufSpeichern=null;
|
|
|
|
katSel.innerHTML=Object.keys(KAT).map(k=>`<option value="${k}">${esc(KAT[k])}</option>`).join('');
|
|
statusSel.innerHTML=DRINGLICHKEITEN.map(x=>`<option value="${x}">${x}</option>`).join('');
|
|
|
|
function schliessen(){
|
|
overlay.classList.remove('zeigen');
|
|
aufSpeichern=null;
|
|
}
|
|
// felder: true zeigt Kategorie/Status-Auswahl, vorbelegt mit den aktuellen
|
|
// Werten - man korrigiert nur, was falsch ist, statt alles neu waehlen zu
|
|
// muessen. aufSpeichern bekommt {text, kategorie, dringlichkeit}.
|
|
function oeffnen({titel,kontext,kategorie,dringlichkeit,felder=true,aufSpeichern:cb}){
|
|
titelEl.textContent=titel||'Meldung';
|
|
kontextEl.textContent=kontext||'';
|
|
kontextEl.style.display=kontext?'block':'none';
|
|
auswahlEl.style.display=felder?'flex':'none';
|
|
if(felder){
|
|
katSel.value=kategorie||'sonstiges';
|
|
statusSel.value=dringlichkeit||'normal';
|
|
}
|
|
textEl.value='';
|
|
aufSpeichern=cb;
|
|
overlay.classList.add('zeigen');
|
|
textEl.focus();
|
|
}
|
|
document.getElementById('meldungAbbrechen').onclick=schliessen;
|
|
document.getElementById('meldungSpeichern').onclick=async()=>{
|
|
const text=textEl.value.trim();
|
|
if(!text){textEl.focus();return}
|
|
const cb=aufSpeichern;
|
|
const daten={text};
|
|
if(auswahlEl.style.display!=='none'){
|
|
daten.kategorie=katSel.value;
|
|
daten.dringlichkeit=statusSel.value;
|
|
}
|
|
schliessen();
|
|
if(cb)await cb(daten);
|
|
};
|
|
overlay.onclick=e=>{if(e.target===overlay)schliessen()};
|
|
document.addEventListener('keydown',e=>{
|
|
if(e.key==='Escape'&&overlay.classList.contains('zeigen'))schliessen();
|
|
});
|
|
return {oeffnen,schliessen};
|
|
})();
|
|
|
|
// Sicher-Modus merkt sich den Zustand pro Browser (localStorage) - beim
|
|
// naechsten Aufruf (z.B. vor einer Bildschirmaufnahme) nicht vergessen.
|
|
// Bei Testzugang (per "agent-one test"-Link, s. api/status.testzugang) ist
|
|
// er erzwungen an und laesst sich nicht abschalten - der Link braucht kein
|
|
// Passwort, also duerfen fremde Tester keine echten Rufnummern sehen.
|
|
const sicherBtn=document.getElementById('sicherBtn');
|
|
let sicherErzwungen=false;
|
|
function sicherSetzen(an){
|
|
document.body.classList.toggle('sicher',an);
|
|
sicherBtn.classList.toggle('aktiv',an);
|
|
try{localStorage.setItem('sicherModus',an?'1':'0')}catch(e){}
|
|
}
|
|
try{sicherSetzen(localStorage.getItem('sicherModus')==='1')}catch(e){}
|
|
sicherBtn.onclick=()=>{
|
|
if(sicherErzwungen){toast('Sicher-Modus ist bei Testzugang immer aktiv');return}
|
|
sicherSetzen(!document.body.classList.contains('sicher'));
|
|
};
|
|
|
|
// Kategorisierungs-Modell (Dropdown oben rechts) - Wechsel greift sofort
|
|
// beim naechsten Anruf, kein Neustart des Watchers noetig (siehe
|
|
// pipe/modellwahl.py, geteilte Zustandsdatei).
|
|
async function modellLaden(){
|
|
const d=await hole('/api/modell'); if(!d)return;
|
|
const sel=document.getElementById('modellSelect');
|
|
sel.innerHTML=d.optionen.map(o=>
|
|
`<option value="${esc(o.id)}"${o.id===d.aktuell?' selected':''}>${esc(o.label)}</option>`).join('');
|
|
}
|
|
document.getElementById('modellSelect').onchange=async e=>{
|
|
const label=e.target.selectedOptions[0].textContent;
|
|
const r=await fetch('/api/modell',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({id:e.target.value})});
|
|
const d=await r.json().catch(()=>null);
|
|
toast(d&&d.ok?`Modell gewechselt: ${label}`:'Umschalten fehlgeschlagen');
|
|
};
|
|
modellLaden();
|
|
|
|
document.getElementById('archivBtn').onclick=async()=>{
|
|
const btn=document.getElementById('archivBtn');
|
|
const letzter=STAPEL[STAPEL.length-1];
|
|
const anzahl=ANRUFE.filter(a=>a.stapel===letzter).length;
|
|
if(!anzahl){toast(`Nichts zu archivieren — ${letzter||'letzter Stapel'} ist leer.`);return}
|
|
if(!confirm(`${anzahl} Anruf(e) aus "${letzter}" archivieren? (simuliert — nichts wird geloescht)`))return;
|
|
btn.disabled=true;
|
|
const r=await fetch('/api/archivieren',{method:'POST'});
|
|
const d=await r.json().catch(()=>null);
|
|
btn.disabled=false;
|
|
if(d&&d.ok){
|
|
let text=`✓ ${d.anzahl} Anruf(e) archiviert (simuliert)`;
|
|
if(d.export_anzahl)text+=` · ${d.export_anzahl} bewertete Anrufe exportiert`;
|
|
if(d.log_archiviert)text+=' · Verlauf gesichert & geleert';
|
|
toast(text);takt();
|
|
} else{toast('Archivieren fehlgeschlagen.')}
|
|
};
|
|
|
|
document.getElementById('exportBtn').onclick=async()=>{
|
|
const bewertete=ANRUFE.filter(a=>a.bewertung).length;
|
|
if(!confirm(`${bewertete} bewertete Anrufe auf dem aktuellen Board gefunden. `
|
|
+'Export ist für externe Auswertung gedacht (z.B. Modell-Verbesserung) — '
|
|
+'Transkripte und Namen der bewerteten Anrufe verlassen damit den Rechner. Fortfahren?'))return;
|
|
const r=await fetch('/api/export',{method:'POST'});
|
|
const d=await r.json().catch(()=>null);
|
|
toast(d&&d.ok?`✓ ${d.anzahl} Anruf(e) exportiert → training/${d.datei}`:'Export fehlgeschlagen');
|
|
};
|
|
|
|
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())
|