feat: Modell-Dropdown listet lokale Ollama-Modelle live statt fest im Code

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
This commit is contained in:
Jeuner 2026-08-28 16:53:45 +02:00
parent 2605710818
commit d39b360b7b
2 changed files with 82 additions and 26 deletions

View file

@ -5,49 +5,104 @@ Der Watcher (pipe.watch, Langzeit-Prozess) und der Leitstand (pipe.server,
eigener Prozess) laufen getrennt - die Auswahl wird deshalb in einer kleinen eigener Prozess) laufen getrennt - die Auswahl wird deshalb in einer kleinen
JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein
Caching), damit eine Umschaltung sofort beim naechsten Anruf greift. Caching), damit eine Umschaltung sofort beim naechsten Anruf greift.
Die lokalen Ollama-Modelle werden live von Ollama abgefragt (GET /api/tags)
statt fest im Code zu stehen - jedes per "ollama pull" installierte Modell
taucht automatisch im Dropdown auf, ohne Code-Aenderung. Eine feste
OpenRouter-Testauswahl bleibt daneben bestehen: die Modell-IDs dort lassen
sich nicht lokal verifizieren, ein Tippfehler wuerde sonst erst beim naechsten
Anruf sichtbar - fuer freie Eingabe siehe OPENROUTER_MODELL in der .env.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import time
import urllib.error
import urllib.request
from . import config from . import config
DATEI = config.TELEFON / "modell.json" DATEI = config.TELEFON / "modell.json"
# Feste, kuratierte Auswahl statt freier Eingabe - ein Tippfehler in einer # Ollama-Abfrage ist ein Netzwerkaufruf - kurz zwischenspeichern, damit ein
# Modell-ID im Leitstand darf nicht den naechsten Anruf scheitern lassen. # offener Leitstand (Polling alle paar Sekunden) Ollama nicht dauerbelastet.
# "openrouter"-Eintraege sind zum Testen gedacht (Transkript verlaesst dabei _CACHE: dict[str, tuple[float, list[dict]]] = {}
# den Rechner) - nicht fuer den Praxisbetrieb. CACHE_S = 30.0
AUSWAHL = [
{"id": "ollama", "label": f"{config.OLLAMA_MODELL} (lokal)",
"backend": "ollama", "modell": config.OLLAMA_MODELL}, def _ollama_modelle() -> list[str]:
{"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)", """Namen der lokal installierten, textfähigen Ollama-Modelle (leer bei Fehler).
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"},
{"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)", Reine Embedding-Modelle (z. B. nomic-embed-text) werden ausgefiltert - sie
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"}, können nicht kategorisieren, ein Aufruf würde nur mit einem Fehler enden.
] Ältere Ollama-Versionen liefern kein "capabilities"-Feld - dann bleibt das
# Optionaler vierter Eintrag zum schnellen Ausprobieren eines beliebigen Modell sicherheitshalber drin, statt es zu Unrecht zu verstecken.
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in """
# .env setzen. req = urllib.request.Request(f"{config.OLLAMA_URL}/api/tags",
if config.OPENROUTER_MODELL: headers={"User-Agent": "praxis-telefon-agent/1.0"})
AUSWAHL.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)", try:
"backend": "openrouter", "modell": config.OPENROUTER_MODELL}) with urllib.request.urlopen(req, timeout=5) as antwort:
_STANDARD = AUSWAHL[0]["id"] daten = json.loads(antwort.read().decode("utf-8"))
_NACH_ID = {e["id"]: e for e in AUSWAHL} except Exception:
return []
namen = []
for m in daten.get("models", []):
name = m.get("name")
faehigkeiten = m.get("capabilities")
if name and (faehigkeiten is None or "completion" in faehigkeiten):
namen.append(name)
return sorted(namen)
def auswahl() -> list[dict]:
"""Aktuelle Dropdown-Optionen: lokale Ollama-Modelle live + feste OpenRouter-Testeintraege."""
jetzt = time.time()
gepuffert = _CACHE.get("auswahl")
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
return gepuffert[1]
# Ist Ollama gerade nicht erreichbar, bleibt wenigstens die konfigurierte
# Standardauswahl im Dropdown - ein leeres Dropdown waere schlechter.
lokale = _ollama_modelle() or [config.OLLAMA_MODELL]
liste = [{"id": f"ollama:{name}", "label": f"{name} (lokal)",
"backend": "ollama", "modell": name} for name in lokale]
liste.append({"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)",
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"})
liste.append({"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)",
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"})
# Optionaler Eintrag zum schnellen Ausprobieren eines beliebigen
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in
# .env setzen.
if config.OPENROUTER_MODELL:
liste.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)",
"backend": "openrouter", "modell": config.OPENROUTER_MODELL})
_CACHE["auswahl"] = (jetzt, liste)
return liste
def _standard_id(optionen: list[dict]) -> str:
"""Bevorzugt das in der .env konfigurierte Modell, sonst die erste Option."""
for eintrag in optionen:
if eintrag["backend"] == "ollama" and eintrag["modell"] == config.OLLAMA_MODELL:
return eintrag["id"]
return optionen[0]["id"]
def aktuelle_id() -> str: def aktuelle_id() -> str:
"""Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard.""" """Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard."""
optionen = auswahl()
standard = _standard_id(optionen)
try: try:
wert = json.loads(DATEI.read_text(encoding="utf-8")).get("id") wert = json.loads(DATEI.read_text(encoding="utf-8")).get("id")
except Exception: except Exception:
return _STANDARD return standard
return wert if wert in _NACH_ID else _STANDARD return wert if wert in {e["id"] for e in optionen} else standard
def setze(kennung: str) -> bool: def setze(kennung: str) -> bool:
"""Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist.""" """Schreibt die Auswahl. False, wenn die ID nicht (mehr) vorgesehen ist."""
if kennung not in _NACH_ID: if kennung not in {e["id"] for e in auswahl()}:
return False return False
try: try:
DATEI.parent.mkdir(parents=True, exist_ok=True) DATEI.parent.mkdir(parents=True, exist_ok=True)
@ -59,5 +114,6 @@ def setze(kennung: str) -> bool:
def backend_und_modell() -> tuple[str, str]: def backend_und_modell() -> tuple[str, str]:
"""Backend + Modell-ID der aktiven Auswahl, fuer categorize.py.""" """Backend + Modell-ID der aktiven Auswahl, fuer categorize.py."""
eintrag = _NACH_ID[aktuelle_id()] kennung = aktuelle_id()
eintrag = next(e for e in auswahl() if e["id"] == kennung)
return eintrag["backend"], eintrag["modell"] return eintrag["backend"], eintrag["modell"]

View file

@ -255,7 +255,7 @@ class Handler(BaseHTTPRequestHandler):
eintraege = _maskiere_verlauf(eintraege) eintraege = _maskiere_verlauf(eintraege)
self._json(eintraege) self._json(eintraege)
elif pfad == "/api/modell": elif pfad == "/api/modell":
self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.AUSWAHL}) 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: