mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +02:00
feat: OpenRouter-Backend + Modell-Dropdown im Leitstand, Live-Log fuer Dauer/Tokens/Kosten
Zweites Kategorisierungs-Backend neben Ollama (pipe/categorize.py), fuer Vergleichstests mit staerkeren Cloud-Modellen. Umschaltbar per Dropdown oben rechts im Leitstand (pipe/modellwahl.py, geteilte telefon/modell.json) - der laufende Watcher braucht dafuer keinen Neustart, die Auswahl wird bei jedem Anruf frisch gelesen. Jede Kategorisierung loggt jetzt Backend/Modell/Dauer/Tokens/Kosten mit ins Leitstand-Verlauf (protokoll.schreibe), nicht nur versteckt in meta.json. Testergebnis (Nemotron ueber OpenRouter, :free-Tier): stark schwankende Latenz (8,5s bis 80s je nach Modell/Auslastung), einmal 502 "temporarily overloaded" - :free-Modelle sind fuer spaeter geplanten Echtzeit-Dialog ungeeignet, dafuer braucht es ein bezahltes/dediziertes Tier. telefon/modell.json ist Laufzeit-Zustand, kein Quellcode - neu ignoriert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
This commit is contained in:
parent
4109aee1bb
commit
3e2d829542
7 changed files with 230 additions and 18 deletions
|
|
@ -12,9 +12,16 @@ WHISPER_THREADS=8
|
||||||
WHISPER_MODELL=medium
|
WHISPER_MODELL=medium
|
||||||
WHISPER_COMPUTE=int8
|
WHISPER_COMPUTE=int8
|
||||||
|
|
||||||
# --- Kategorisierung (Ollama, lokal) ---
|
# --- Kategorisierung ---
|
||||||
|
# Backend: "ollama" (Default, lokal) oder "openrouter" (Cloud, nur zum Testen
|
||||||
|
# ob ein staerkeres Modell besser extrahiert - Transkript verlaesst dabei den
|
||||||
|
# Rechner, nicht im Praxisbetrieb einsetzen).
|
||||||
|
KATEGORISIERUNG_BACKEND=ollama
|
||||||
OLLAMA_URL=http://127.0.0.1:11434
|
OLLAMA_URL=http://127.0.0.1:11434
|
||||||
OLLAMA_MODELL=qwen3:8b
|
OLLAMA_MODELL=qwen3:8b
|
||||||
|
# Nur fuer KATEGORISIERUNG_BACKEND=openrouter:
|
||||||
|
OPENROUTER_KEY=
|
||||||
|
OPENROUTER_MODELL=
|
||||||
|
|
||||||
# --- Nextcloud (leer lassen = nur lokale Ablage) ---
|
# --- Nextcloud (leer lassen = nur lokale Ablage) ---
|
||||||
NEXTCLOUD_URL=
|
NEXTCLOUD_URL=
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -12,3 +12,5 @@ __pycache__/
|
||||||
/telefon/ansage.wav
|
/telefon/ansage.wav
|
||||||
/telefon/verlauf.log
|
/telefon/verlauf.log
|
||||||
/telefon/*.log
|
/telefon/*.log
|
||||||
|
# Laufzeit-Zustand (aktive Modell-Auswahl aus dem Leitstand-Dropdown), kein Quellcode
|
||||||
|
/telefon/modell.json
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
"""Kategorisierung: Transkript -> strukturierte Felder (Ollama/Qwen, lokal).
|
"""Kategorisierung: Transkript -> strukturierte Felder.
|
||||||
|
|
||||||
Nutzt den Chat-Endpunkt von Ollama mit erzwungenem JSON-Schema. Denk-Tokens des
|
Zwei Backends (Auswahl siehe pipe.modellwahl):
|
||||||
Modells landen separat im "thinking"-Feld und werden ignoriert; zurück kommt
|
- "ollama" (Default): lokal, erzwungenes JSON-Schema, Denk-Tokens landen
|
||||||
reines JSON.
|
separat im "thinking"-Feld und werden ignoriert.
|
||||||
|
- "openrouter": Cloud, zum Testen ob ein staerkeres Modell besser extrahiert.
|
||||||
|
Transkript verlaesst dabei den Rechner - nur zum Testen.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from . import config
|
from . import config, modellwahl
|
||||||
|
|
||||||
KATEGORIEN = [
|
KATEGORIEN = [
|
||||||
"termin", "rezept", "ueberweisung", "befund",
|
"termin", "rezept", "ueberweisung", "befund",
|
||||||
|
|
@ -51,13 +54,35 @@ class KategorisierungsFehler(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def kategorisiere(transkript: str) -> dict:
|
def kategorisiere(transkript: str) -> tuple[dict, dict]:
|
||||||
"""Ordnet ein Transkript ein. Wirft KategorisierungsFehler bei Problemen."""
|
"""Ordnet ein Transkript ein. Wirft KategorisierungsFehler bei Problemen.
|
||||||
|
|
||||||
|
Gibt (auswertung, meta) zurueck - meta fuers Live-Log im Leitstand:
|
||||||
|
backend, modell, dauer_s, tokens, kosten_usd (letzteres nur OpenRouter,
|
||||||
|
bei :free-Modellen 0.0).
|
||||||
|
|
||||||
|
Backend + Modell kommen aus pipe.modellwahl (per Leitstand-Dropdown
|
||||||
|
umschaltbar, kein Neustart noetig) - Fallback ist config.py, wenn dort
|
||||||
|
keine Auswahl getroffen wurde.
|
||||||
|
"""
|
||||||
if not transkript.strip():
|
if not transkript.strip():
|
||||||
raise KategorisierungsFehler("Leeres Transkript kann nicht kategorisiert werden.")
|
raise KategorisierungsFehler("Leeres Transkript kann nicht kategorisiert werden.")
|
||||||
|
|
||||||
|
backend, modell = modellwahl.backend_und_modell()
|
||||||
|
start = time.monotonic()
|
||||||
|
if backend == "openrouter":
|
||||||
|
ergebnis, meta = _mit_openrouter(transkript, modell)
|
||||||
|
else:
|
||||||
|
ergebnis, meta = _mit_ollama(transkript, modell)
|
||||||
|
meta["backend"] = backend
|
||||||
|
meta["modell"] = modell
|
||||||
|
meta["dauer_s"] = round(time.monotonic() - start, 2)
|
||||||
|
return _bereinige(ergebnis), meta
|
||||||
|
|
||||||
|
|
||||||
|
def _mit_ollama(transkript: str, modell: str) -> tuple[dict, dict]:
|
||||||
anfrage = {
|
anfrage = {
|
||||||
"model": config.OLLAMA_MODELL,
|
"model": modell,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": _prompt()},
|
{"role": "system", "content": _prompt()},
|
||||||
{"role": "user", "content": f"Transkript der Sprachnachricht:\n\n{transkript}"},
|
{"role": "user", "content": f"Transkript der Sprachnachricht:\n\n{transkript}"},
|
||||||
|
|
@ -85,12 +110,65 @@ def kategorisiere(transkript: str) -> dict:
|
||||||
inhalt = roh.get("message", {}).get("content", "").strip()
|
inhalt = roh.get("message", {}).get("content", "").strip()
|
||||||
if not inhalt:
|
if not inhalt:
|
||||||
raise KategorisierungsFehler("Ollama lieferte keine Antwort.")
|
raise KategorisierungsFehler("Ollama lieferte keine Antwort.")
|
||||||
|
# eval_count/prompt_eval_count sind Ollamas Token-Zaehler - kein Standard-
|
||||||
|
# OpenAI-Feldname, aber inhaltlich dasselbe (Antwort-/Prompt-Tokens).
|
||||||
|
tokens = roh.get("prompt_eval_count", 0) + roh.get("eval_count", 0) or None
|
||||||
try:
|
try:
|
||||||
ergebnis = json.loads(inhalt)
|
return json.loads(inhalt), {"tokens": tokens, "kosten_usd": None}
|
||||||
except json.JSONDecodeError as fehler:
|
except json.JSONDecodeError as fehler:
|
||||||
raise KategorisierungsFehler(f"Antwort war kein gültiges JSON: {inhalt[:200]}") from fehler
|
raise KategorisierungsFehler(f"Antwort war kein gültiges JSON: {inhalt[:200]}") from fehler
|
||||||
|
|
||||||
return _bereinige(ergebnis)
|
|
||||||
|
def _mit_openrouter(transkript: str, modell: str) -> tuple[dict, dict]:
|
||||||
|
if not config.OPENROUTER_KEY:
|
||||||
|
raise KategorisierungsFehler("OPENROUTER_KEY fehlt in .env.")
|
||||||
|
|
||||||
|
# OpenAI-kompatible Chat-API. json_object statt json_schema, weil nicht
|
||||||
|
# jedes ueber OpenRouter geroutete Modell striktes Schema unterstuetzt -
|
||||||
|
# der Systemprompt beschreibt die Felder bereits explizit in Prosa.
|
||||||
|
# usage.include=true ist eine OpenRouter-Erweiterung: liefert zusaetzlich
|
||||||
|
# die tatsaechlichen Kosten (USD) in der Antwort mit, nicht nur Tokens.
|
||||||
|
anfrage = {
|
||||||
|
"model": modell,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": _prompt()},
|
||||||
|
{"role": "user", "content": f"Transkript der Sprachnachricht:\n\n{transkript}"},
|
||||||
|
],
|
||||||
|
"temperature": 0,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"usage": {"include": True},
|
||||||
|
}
|
||||||
|
daten = json.dumps(anfrage).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
data=daten,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {config.OPENROUTER_KEY}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as antwort:
|
||||||
|
roh = json.loads(antwort.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as fehler:
|
||||||
|
detail = fehler.read().decode("utf-8", errors="replace")[:200]
|
||||||
|
raise KategorisierungsFehler(f"OpenRouter-Fehler {fehler.code}: {detail}") from fehler
|
||||||
|
except urllib.error.URLError as fehler:
|
||||||
|
raise KategorisierungsFehler(f"OpenRouter nicht erreichbar: {fehler}") from fehler
|
||||||
|
|
||||||
|
try:
|
||||||
|
inhalt = roh["choices"][0]["message"]["content"].strip()
|
||||||
|
except (KeyError, IndexError) as fehler:
|
||||||
|
raise KategorisierungsFehler(f"Unerwartete OpenRouter-Antwort: {roh}") from fehler
|
||||||
|
if not inhalt:
|
||||||
|
raise KategorisierungsFehler("OpenRouter lieferte keine Antwort.")
|
||||||
|
nutzung = roh.get("usage") or {}
|
||||||
|
meta = {"tokens": nutzung.get("total_tokens"), "kosten_usd": nutzung.get("cost")}
|
||||||
|
try:
|
||||||
|
return json.loads(inhalt), meta
|
||||||
|
except json.JSONDecodeError as fehler:
|
||||||
|
raise KategorisierungsFehler(f"Antwort war kein gültiges JSON: {inhalt[:200]}") from fehler
|
||||||
|
|
||||||
|
|
||||||
def _bereinige(d: dict) -> dict:
|
def _bereinige(d: dict) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,21 @@ WHISPER_PROMPT = os.environ.get(
|
||||||
WHISPER_MODELL = os.environ.get("WHISPER_MODELL", "medium")
|
WHISPER_MODELL = os.environ.get("WHISPER_MODELL", "medium")
|
||||||
WHISPER_COMPUTE = os.environ.get("WHISPER_COMPUTE", "int8")
|
WHISPER_COMPUTE = os.environ.get("WHISPER_COMPUTE", "int8")
|
||||||
|
|
||||||
# --- Kategorisierung (Ollama, lokal) -----------------------------------------
|
# --- Kategorisierung -----------------------------------------------------
|
||||||
|
# Welches Modell aktiv ist, waehlt der Leitstand per Dropdown (pipe.modellwahl,
|
||||||
|
# geteilte Zustandsdatei telefon/modell.json) - kein Neustart noetig. Hier nur
|
||||||
|
# die Bausteine dafuer: Ollama-Zugang (lokal, Default-Auswahl) und der
|
||||||
|
# OpenRouter-Zugang fuers Testen staerkerer Cloud-Modelle. Patientendaten
|
||||||
|
# (Transkript) verlassen bei OpenRouter den Rechner - nur zu Testzwecken,
|
||||||
|
# nicht im Praxisbetrieb.
|
||||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
|
||||||
OLLAMA_MODELL = os.environ.get("OLLAMA_MODELL", "qwen3:8b")
|
OLLAMA_MODELL = os.environ.get("OLLAMA_MODELL", "qwen3:8b")
|
||||||
|
|
||||||
|
OPENROUTER_KEY = os.environ.get("OPENROUTER_KEY", "")
|
||||||
|
# Optional: eigenes Testmodell zusaetzlich zu den zwei fest im Dropdown
|
||||||
|
# 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 = WURZEL / "prompts" / os.environ.get(
|
||||||
"PROMPT_DATEI", "categorize_de.txt"
|
"PROMPT_DATEI", "categorize_de.txt"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
63
pipe/modellwahl.py
Normal file
63
pipe/modellwahl.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Aktives Kategorisierungs-Modell - umschaltbar aus dem Leitstand (Dropdown
|
||||||
|
oben rechts), ohne .env-Aenderung oder Neustart des Watchers.
|
||||||
|
|
||||||
|
Der Watcher (pipe.watch, Langzeit-Prozess) und der Leitstand (pipe.server,
|
||||||
|
eigener Prozess) laufen getrennt - die Auswahl wird deshalb in einer kleinen
|
||||||
|
JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein
|
||||||
|
Caching), damit eine Umschaltung sofort beim naechsten Anruf greift.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
DATEI = config.TELEFON / "modell.json"
|
||||||
|
|
||||||
|
# Feste, kuratierte Auswahl statt freier Eingabe - ein Tippfehler in einer
|
||||||
|
# Modell-ID im Leitstand darf nicht den naechsten Anruf scheitern lassen.
|
||||||
|
# "openrouter"-Eintraege sind zum Testen gedacht (Transkript verlaesst dabei
|
||||||
|
# den Rechner) - nicht fuer den Praxisbetrieb.
|
||||||
|
AUSWAHL = [
|
||||||
|
{"id": "ollama", "label": f"{config.OLLAMA_MODELL} (lokal)",
|
||||||
|
"backend": "ollama", "modell": config.OLLAMA_MODELL},
|
||||||
|
{"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)",
|
||||||
|
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"},
|
||||||
|
{"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)",
|
||||||
|
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"},
|
||||||
|
]
|
||||||
|
# Optionaler vierter Eintrag zum schnellen Ausprobieren eines beliebigen
|
||||||
|
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in
|
||||||
|
# .env setzen.
|
||||||
|
if config.OPENROUTER_MODELL:
|
||||||
|
AUSWAHL.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)",
|
||||||
|
"backend": "openrouter", "modell": config.OPENROUTER_MODELL})
|
||||||
|
_STANDARD = AUSWAHL[0]["id"]
|
||||||
|
_NACH_ID = {e["id"]: e for e in AUSWAHL}
|
||||||
|
|
||||||
|
|
||||||
|
def aktuelle_id() -> str:
|
||||||
|
"""Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard."""
|
||||||
|
try:
|
||||||
|
wert = json.loads(DATEI.read_text(encoding="utf-8")).get("id")
|
||||||
|
except Exception:
|
||||||
|
return _STANDARD
|
||||||
|
return wert if wert in _NACH_ID else _STANDARD
|
||||||
|
|
||||||
|
|
||||||
|
def setze(kennung: str) -> bool:
|
||||||
|
"""Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist."""
|
||||||
|
if kennung not in _NACH_ID:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
DATEI.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
DATEI.write_text(json.dumps({"id": kennung}), encoding="utf-8")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def backend_und_modell() -> tuple[str, str]:
|
||||||
|
"""Backend + Modell-ID der aktiven Auswahl, fuer categorize.py."""
|
||||||
|
eintrag = _NACH_ID[aktuelle_id()]
|
||||||
|
return eintrag["backend"], eintrag["modell"]
|
||||||
|
|
@ -29,7 +29,7 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
|
||||||
|
|
||||||
print("▸ Kategorisiere …")
|
print("▸ Kategorisiere …")
|
||||||
if t.text.strip():
|
if t.text.strip():
|
||||||
auswertung = categorize.kategorisiere(t.text)
|
auswertung, meta = categorize.kategorisiere(t.text)
|
||||||
else:
|
else:
|
||||||
# Kein Text erkannt: Anrufer hat nach dem Signalton nicht gesprochen
|
# Kein Text erkannt: Anrufer hat nach dem Signalton nicht gesprochen
|
||||||
# oder sofort aufgelegt (oder eine bekannte Whisper-Halluzination
|
# oder sofort aufgelegt (oder eine bekannte Whisper-Halluzination
|
||||||
|
|
@ -46,13 +46,26 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
|
||||||
"aufgelegt.",
|
"aufgelegt.",
|
||||||
"stichworte": [],
|
"stichworte": [],
|
||||||
}
|
}
|
||||||
print(f" Kategorie: {auswertung['kategorie']} · Dringlichkeit: {auswertung['dringlichkeit']}")
|
meta = {"backend": "-", "modell": "-", "dauer_s": 0.0, "tokens": None, "kosten_usd": None}
|
||||||
|
print(f" Kategorie: {auswertung['kategorie']} · Dringlichkeit: {auswertung['dringlichkeit']}"
|
||||||
|
f" ({meta['modell']}, {meta['dauer_s']}s)")
|
||||||
|
|
||||||
|
# Dauer/Tokens/Kosten mit ins Live-Log (Leitstand-Verlauf), nicht nur in
|
||||||
|
# den JSON-Feldern versteckt - fuer den Modell-Vergleich (Dropdown oben
|
||||||
|
# rechts) muss man das sofort sehen, ohne meta.json aufzuklappen.
|
||||||
|
zusatz = f"{meta['dauer_s']}s"
|
||||||
|
if meta.get("tokens"):
|
||||||
|
zusatz += f" · {meta['tokens']} Tok"
|
||||||
|
if meta.get("kosten_usd"):
|
||||||
|
zusatz += f" · ${meta['kosten_usd']:.4f}"
|
||||||
protokoll.schreibe(
|
protokoll.schreibe(
|
||||||
"notfall" if auswertung["dringlichkeit"] == "notfall" else "anruf",
|
"notfall" if auswertung["dringlichkeit"] == "notfall" else "anruf",
|
||||||
f"{auswertung['kategorie']} · {auswertung['dringlichkeit']}"
|
f"{auswertung['kategorie']} · {auswertung['dringlichkeit']}"
|
||||||
+ (f" · {auswertung['anrufer_name']}" if auswertung.get("anrufer_name") else ""),
|
+ (f" · {auswertung['anrufer_name']}" if auswertung.get("anrufer_name") else "")
|
||||||
|
+ f" ({meta['modell']}, {zusatz})",
|
||||||
nummer=anrufer_nummer, kategorie=auswertung["kategorie"],
|
nummer=anrufer_nummer, kategorie=auswertung["kategorie"],
|
||||||
dringlichkeit=auswertung["dringlichkeit"])
|
dringlichkeit=auswertung["dringlichkeit"], modell=meta["modell"],
|
||||||
|
dauer_s=meta["dauer_s"], tokens=meta.get("tokens"), kosten_usd=meta.get("kosten_usd"))
|
||||||
|
|
||||||
bekannter_kontakt = kontakte.nachschlagen(anrufer_nummer)
|
bekannter_kontakt = kontakte.nachschlagen(anrufer_nummer)
|
||||||
if bekannter_kontakt:
|
if bekannter_kontakt:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from . import config, dashboard, protokoll, stapel, store
|
from . import config, dashboard, modellwahl, protokoll, stapel, store
|
||||||
|
|
||||||
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
|
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
|
||||||
# damit haeufiges Nachfragen den Rechner nicht belastet.
|
# damit haeufiges Nachfragen den Rechner nicht belastet.
|
||||||
|
|
@ -277,6 +277,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()})
|
self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()})
|
||||||
elif pfad == "/api/verlauf":
|
elif pfad == "/api/verlauf":
|
||||||
self._json(verlauf(150))
|
self._json(verlauf(150))
|
||||||
|
elif pfad == "/api/modell":
|
||||||
|
self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.AUSWAHL})
|
||||||
elif pfad.startswith("/audio/"):
|
elif pfad.startswith("/audio/"):
|
||||||
self._audio(pfad[len("/audio/"):])
|
self._audio(pfad[len("/audio/"):])
|
||||||
else:
|
else:
|
||||||
|
|
@ -292,6 +294,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
protokoll.schreibe("archiv", f"{anzahl} Anruf(e) archiviert (simuliert)", anzahl=anzahl)
|
protokoll.schreibe("archiv", f"{anzahl} Anruf(e) archiviert (simuliert)", anzahl=anzahl)
|
||||||
self._json({"ok": True, "anzahl": anzahl, "simuliert": True})
|
self._json({"ok": True, "anzahl": anzahl, "simuliert": True})
|
||||||
return
|
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":
|
if pfad != "/api/stapel":
|
||||||
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
|
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
|
||||||
return
|
return
|
||||||
|
|
@ -386,6 +401,8 @@ font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
||||||
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:12px}
|
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:12px}
|
||||||
h1{font-size:19px;margin:0}
|
h1{font-size:19px;margin:0}
|
||||||
.stand{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums;margin-left:auto}
|
.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}
|
||||||
.dienste{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:9px}
|
.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;
|
.dienst{background:var(--karte);border:1px solid var(--rand);border-radius:9px;padding:9px 11px;
|
||||||
display:flex;align-items:center;gap:9px}
|
display:flex;align-items:center;gap:9px}
|
||||||
|
|
@ -471,7 +488,9 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:
|
||||||
<header><h1>Anruf-Leitstand</h1>
|
<header><h1>Anruf-Leitstand</h1>
|
||||||
<button class="sicher-btn" id="sicherBtn" title="Namen, Nummern, Anliegen und Transkripte unscharf - fuer Bildschirmaufnahmen/Screenshots">🙈 Sicher-Modus</button>
|
<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 oder exportiert)">🗄️ Tag archivieren</button>
|
<button class="archiv-btn" id="archivBtn" title="Erledigte Anrufe vom Board raeumen (simuliert - nichts wird geloescht oder exportiert)">🗄️ Tag archivieren</button>
|
||||||
<span class="stand" id="stand">…</span></header>
|
<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 id="verlauf"></div>
|
<div id="verlauf"></div>
|
||||||
<div id="hinweise"></div>
|
<div id="hinweise"></div>
|
||||||
<div class="notfall-banner" id="notfallBanner">🚨 <span id="notfallText"></span></div>
|
<div class="notfall-banner" id="notfallBanner">🚨 <span id="notfallText"></span></div>
|
||||||
|
|
@ -665,6 +684,24 @@ function sicherSetzen(an){
|
||||||
try{sicherSetzen(localStorage.getItem('sicherModus')==='1')}catch(e){}
|
try{sicherSetzen(localStorage.getItem('sicherModus')==='1')}catch(e){}
|
||||||
sicherBtn.onclick=()=>sicherSetzen(!document.body.classList.contains('sicher'));
|
sicherBtn.onclick=()=>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()=>{
|
document.getElementById('archivBtn').onclick=async()=>{
|
||||||
const btn=document.getElementById('archivBtn');
|
const btn=document.getElementById('archivBtn');
|
||||||
const letzter=STAPEL[STAPEL.length-1];
|
const letzter=STAPEL[STAPEL.length-1];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue