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

@ -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];