From d39b360b7b005908bd057e6924653b4a2ffe7bae Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:53:45 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ --- pipe/modellwahl.py | 106 ++++++++++++++++++++++++++++++++++----------- pipe/server.py | 2 +- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/pipe/modellwahl.py b/pipe/modellwahl.py index 0cef52b..d79eff2 100644 --- a/pipe/modellwahl.py +++ b/pipe/modellwahl.py @@ -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 JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein 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 import json +import time +import urllib.error +import urllib.request 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} +# Ollama-Abfrage ist ein Netzwerkaufruf - kurz zwischenspeichern, damit ein +# offener Leitstand (Polling alle paar Sekunden) Ollama nicht dauerbelastet. +_CACHE: dict[str, tuple[float, list[dict]]] = {} +CACHE_S = 30.0 + + +def _ollama_modelle() -> list[str]: + """Namen der lokal installierten, textfähigen Ollama-Modelle (leer bei Fehler). + + Reine Embedding-Modelle (z. B. nomic-embed-text) werden ausgefiltert - sie + können nicht kategorisieren, ein Aufruf würde nur mit einem Fehler enden. + Ältere Ollama-Versionen liefern kein "capabilities"-Feld - dann bleibt das + Modell sicherheitshalber drin, statt es zu Unrecht zu verstecken. + """ + req = urllib.request.Request(f"{config.OLLAMA_URL}/api/tags", + headers={"User-Agent": "praxis-telefon-agent/1.0"}) + try: + with urllib.request.urlopen(req, timeout=5) as antwort: + daten = json.loads(antwort.read().decode("utf-8")) + 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: """Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard.""" + optionen = auswahl() + standard = _standard_id(optionen) 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 + return standard + return wert if wert in {e["id"] for e in optionen} else standard def setze(kennung: str) -> bool: - """Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist.""" - if kennung not in _NACH_ID: + """Schreibt die Auswahl. False, wenn die ID nicht (mehr) vorgesehen ist.""" + if kennung not in {e["id"] for e in auswahl()}: return False try: DATEI.parent.mkdir(parents=True, exist_ok=True) @@ -59,5 +114,6 @@ def setze(kennung: str) -> bool: def backend_und_modell() -> tuple[str, str]: """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"] diff --git a/pipe/server.py b/pipe/server.py index eb2f3fb..24e50d7 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -255,7 +255,7 @@ class Handler(BaseHTTPRequestHandler): eintraege = _maskiere_verlauf(eintraege) self._json(eintraege) 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/"): self._audio(pfad[len("/audio/"):]) else: