diff --git a/.env.example b/.env.example
index 340038c..6a34369 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/.gitignore b/.gitignore
index 9f2552d..d96e2df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/pipe/categorize.py b/pipe/categorize.py
index 329831a..5055c65 100644
--- a/pipe/categorize.py
+++ b/pipe/categorize.py
@@ -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:
diff --git a/pipe/config.py b/pipe/config.py
index 53664fb..e030daa 100644
--- a/pipe/config.py
+++ b/pipe/config.py
@@ -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"
)
diff --git a/pipe/modellwahl.py b/pipe/modellwahl.py
new file mode 100644
index 0000000..0cef52b
--- /dev/null
+++ b/pipe/modellwahl.py
@@ -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"]
diff --git a/pipe/process_call.py b/pipe/process_call.py
index ca95d2b..fb056b5 100644
--- a/pipe/process_call.py
+++ b/pipe/process_call.py
@@ -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:
diff --git a/pipe/server.py b/pipe/server.py
index d3e6616..ace4302 100644
--- a/pipe/server.py
+++ b/pipe/server.py
@@ -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:
Anruf-Leitstand
-…