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

-
+ + +
🚨
@@ -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=> + ``).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];