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:
Jeuner 2026-08-24 21:43:30 +02:00
parent 4109aee1bb
commit 3e2d829542
7 changed files with 230 additions and 18 deletions

View file

@ -12,9 +12,16 @@ WHISPER_THREADS=8
WHISPER_MODELL=medium
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_MODELL=qwen3:8b
# Nur fuer KATEGORISIERUNG_BACKEND=openrouter:
OPENROUTER_KEY=
OPENROUTER_MODELL=
# --- Nextcloud (leer lassen = nur lokale Ablage) ---
NEXTCLOUD_URL=

2
.gitignore vendored
View file

@ -12,3 +12,5 @@ __pycache__/
/telefon/ansage.wav
/telefon/verlauf.log
/telefon/*.log
# Laufzeit-Zustand (aktive Modell-Auswahl aus dem Leitstand-Dropdown), kein Quellcode
/telefon/modell.json

View file

@ -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
Modells landen separat im "thinking"-Feld und werden ignoriert; zurück kommt
reines JSON.
Zwei Backends (Auswahl siehe pipe.modellwahl):
- "ollama" (Default): lokal, erzwungenes JSON-Schema, Denk-Tokens landen
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
import json
import time
import urllib.error
import urllib.request
from functools import lru_cache
from . import config
from . import config, modellwahl
KATEGORIEN = [
"termin", "rezept", "ueberweisung", "befund",
@ -51,13 +54,35 @@ class KategorisierungsFehler(RuntimeError):
pass
def kategorisiere(transkript: str) -> dict:
"""Ordnet ein Transkript ein. Wirft KategorisierungsFehler bei Problemen."""
def kategorisiere(transkript: str) -> tuple[dict, dict]:
"""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():
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 = {
"model": config.OLLAMA_MODELL,
"model": modell,
"messages": [
{"role": "system", "content": _prompt()},
{"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()
if not inhalt:
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:
ergebnis = json.loads(inhalt)
return json.loads(inhalt), {"tokens": tokens, "kosten_usd": None}
except json.JSONDecodeError as 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:

View file

@ -66,9 +66,21 @@ WHISPER_PROMPT = os.environ.get(
WHISPER_MODELL = os.environ.get("WHISPER_MODELL", "medium")
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_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", "categorize_de.txt"
)

63
pipe/modellwahl.py Normal file
View 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"]

View file

@ -29,7 +29,7 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
print("▸ Kategorisiere …")
if t.text.strip():
auswertung = categorize.kategorisiere(t.text)
auswertung, meta = categorize.kategorisiere(t.text)
else:
# Kein Text erkannt: Anrufer hat nach dem Signalton nicht gesprochen
# oder sofort aufgelegt (oder eine bekannte Whisper-Halluzination
@ -46,13 +46,26 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
"aufgelegt.",
"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(
"notfall" if auswertung["dringlichkeit"] == "notfall" else "anruf",
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"],
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)
if bekannter_kontakt:

View file

@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
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,
# damit haeufiges Nachfragen den Rechner nicht belastet.
@ -277,6 +277,8 @@ class Handler(BaseHTTPRequestHandler):
self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()})
elif pfad == "/api/verlauf":
self._json(verlauf(150))
elif pfad == "/api/modell":
self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.AUSWAHL})
elif pfad.startswith("/audio/"):
self._audio(pfad[len("/audio/"):])
else:
@ -292,6 +294,19 @@ class Handler(BaseHTTPRequestHandler):
protokoll.schreibe("archiv", f"{anzahl} Anruf(e) archiviert (simuliert)", anzahl=anzahl)
self._json({"ok": True, "anzahl": anzahl, "simuliert": 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
@ -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}
h1{font-size:19px;margin:0}
.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}
.dienst{background:var(--karte);border:1px solid var(--rand);border-radius:9px;padding:9px 11px;
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>
<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>
<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="hinweise"></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){}
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()=>{
const btn=document.getElementById('archivBtn');
const letzter=STAPEL[STAPEL.length-1];