Ollama-Konfig: Persistenz (localStorage) + E2E-Test, ehrliche Modell-Grenze

Goal 'Ollama-Konfig bleibt bestehen & klappt', mit Tests:
- Persistenz-Fix: Anbieter + Modell in localStorage gemerkt, init() stellt sie
  wieder her (URL-Param > gemerkt > Default). Behebt das Zurückspringen auf
  OpenRouter nach Reload.
- E2E-Test (Playwright): Anbieter überlebt echten Reload. content-JSON-Fallback
  mit 3 Tests abgesichert. 57 Tests grün.
- Ehrlich dokumentiert (Code-Untersuchung + UI-Hinweis): kleine lokale Modelle
  (qwen2.5-coder, gemma4, qwen3.5, llama3.2) liefern kein brauchbares Batch-
  Tool-Calling -> Ergebnis 'Sonstiges/unsicher' (markiert, nicht geraten).
  Brauchbare lokale Kategorisierung braucht ein starkes tool-Modell; Cloud
  (deepseek) bleibt die verlässliche Wahl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeuner 2026-06-03 17:34:34 +02:00
parent 2e80f0d826
commit 2ffb89a6d2
4 changed files with 160 additions and 8 deletions

View file

@ -12,4 +12,5 @@ uvicorn>=0.29
# Entwicklung/Tests: # Entwicklung/Tests:
pytest>=8.0 pytest>=8.0
httpx>=0.27 # für fastapi.testclient httpx>=0.27 # für fastapi.testclient
pytest-playwright>=0.5 # E2E-UI-Tests (danach: `playwright install chromium`)

View file

@ -335,14 +335,27 @@ function setStatus(html){ $("#status").innerHTML = html; }
// ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) ------------------------- // ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) -------------------------
function anbieter(){ return $("#anbieter").value; } function anbieter(){ return $("#anbieter").value; }
// Auswahl in localStorage merken/laden, damit Anbieter + Modell einen Reload
// überleben (statt auf den Default zurückzuspringen).
function merkeWahl(){
try {
localStorage.setItem("ang_anbieter", anbieter());
const m = $("#modell").value;
if (m) localStorage.setItem("ang_modell_" + anbieter(), m);
} catch(e){}
}
function gemerkterAnbieter(){ try { return localStorage.getItem("ang_anbieter"); } catch(e){ return null; } }
function gemerktesModell(ab){ try { return localStorage.getItem("ang_modell_" + ab); } catch(e){ return null; } }
// Hält das dauerhaft sichtbare "gewähltes Modell"-Chip im Konfig-Kopf aktuell -- // Hält das dauerhaft sichtbare "gewähltes Modell"-Chip im Konfig-Kopf aktuell --
// auch wenn das Panel zugeklappt ist. // auch wenn das Panel zugeklappt ist -- und merkt die Wahl.
function setGewaehlt(){ function setGewaehlt(){
const sel = $("#modell"); const sel = $("#modell");
const opt = sel.options[sel.selectedIndex]; const opt = sel.options[sel.selectedIndex];
const m = (sel.value && opt && !opt.disabled) ? sel.value : null; const m = (sel.value && opt && !opt.disabled) ? sel.value : null;
const ab = anbieter() === "ollama" ? "Ollama (lokal)" : "OpenRouter"; const ab = anbieter() === "ollama" ? "Ollama (lokal)" : "OpenRouter";
$("#gewaehltChip").textContent = m ? `${m} · ${ab}` : `kein Modell · ${ab}`; $("#gewaehltChip").textContent = m ? `${m} · ${ab}` : `kein Modell · ${ab}`;
merkeWahl();
} }
async function ladeModelle(q=""){ async function ladeModelle(q=""){
@ -351,8 +364,8 @@ async function ladeModelle(q=""){
// Anbieter-spezifische UI: Ollama braucht weder Key noch Suche. // Anbieter-spezifische UI: Ollama braucht weder Key noch Suche.
$("#keyzeile").style.display = ab === "ollama" ? "none" : ""; $("#keyzeile").style.display = ab === "ollama" ? "none" : "";
$("#suchfeld").style.display = ab === "ollama" ? "none" : ""; $("#suchfeld").style.display = ab === "ollama" ? "none" : "";
$("#konfighint").textContent = ab === "ollama" $("#konfighint").innerHTML = ab === "ollama"
? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. Nur tool-fähige Modelle eignen sich." ? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. <b>Achtung:</b> kleine Modelle (39 B) liefern für diese Batch-Aufgabe oft kein zuverlässiges Tool-Calling — dann wird ehrlich alles als „Sonstiges/unsicher“ markiert statt geraten. Für brauchbare Ergebnisse ein starkes tool-Modell oder OpenRouter nutzen."
: "Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt."; : "Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.";
const url = ab === "ollama" ? "/api/ollama-modelle" : ("/api/modelle?q=" + encodeURIComponent(q)); const url = ab === "ollama" ? "/api/ollama-modelle" : ("/api/modelle?q=" + encodeURIComponent(q));
sel.innerHTML = "<option>lädt…</option>"; sel.innerHTML = "<option>lädt…</option>";
@ -376,8 +389,15 @@ async function ladeModelle(q=""){
if (!m.tools) o.disabled = true; if (!m.tools) o.disabled = true;
sel.appendChild(o); sel.appendChild(o);
} }
const ok = [...sel.options].find(o => !o.disabled && o.value); // gemerktes Modell für diesen Anbieter bevorzugen, sonst erstes brauchbares
if (ok) sel.value = ok.value; // erstes brauchbares (tool-fähiges) vorwählen const opts = [...sel.options];
const gemerkt = gemerktesModell(ab);
if (gemerkt && opts.some(o => o.value === gemerkt && !o.disabled)){
sel.value = gemerkt;
} else {
const ok = opts.find(o => !o.disabled && o.value);
if (ok) sel.value = ok.value;
}
setGewaehlt(); setGewaehlt();
} catch (e){ } catch (e){
sel.innerHTML = `<option value=''>Modelle nicht abrufbar</option>`; sel.innerHTML = `<option value=''>Modelle nicht abrufbar</option>`;
@ -610,7 +630,9 @@ for (const id of ["#fhaendler","#ftext","#fsicher"])
// Bookmarkbar: ?plz=… [&anbieter=…] [&modell=…] [&auto=1] // Bookmarkbar: ?plz=… [&anbieter=…] [&modell=…] [&auto=1]
(async function init(){ (async function init(){
const _p = new URLSearchParams(location.search); const _p = new URLSearchParams(location.search);
if (_p.get("anbieter")) $("#anbieter").value = _p.get("anbieter"); // Anbieter: URL-Param hat Vorrang, sonst die gemerkte Wahl, sonst Default.
const ab = _p.get("anbieter") || gemerkterAnbieter();
if (ab) $("#anbieter").value = ab;
await ladeModelle(); await ladeModelle();
const m = _p.get("modell"); const m = _p.get("modell");
if (m){ if (m){

View file

@ -165,4 +165,39 @@ def test_ollama_kategorisierer_parst_tool_calls_lokal():
assert ergebnis[0].gruppe == "Obst & Gemüse" assert ergebnis[0].gruppe == "Obst & Gemüse"
# OpenAI-kompatibel an den lokalen Endpoint adressiert: # OpenAI-kompatibel an den lokalen Endpoint adressiert:
assert sess.calls[0]["url"].endswith("/chat/completions") assert sess.calls[0]["url"].endswith("/chat/completions")
assert "localhost:11434" in sess.calls[0]["url"] assert "localhost:11434" in sess.calls[0]["url"]
def test_content_fallback_extrahiert_zuordnungen():
from angebote.kategorisieren import _zuordnungen_aus_content
# Manche (lokale) Modelle verpacken die Tool-Antwort als content-JSON.
c = (
'```json\n{"name":"zuordnungen","arguments":{"zuordnungen":'
'[{"id":"x","gruppe":"Obst & Gemüse","unsicher":false}]}}\n```'
)
z = _zuordnungen_aus_content(c)
assert z == [{"id": "x", "gruppe": "Obst & Gemüse", "unsicher": False}]
def test_content_fallback_ohne_json_gibt_none():
from angebote.kategorisieren import _zuordnungen_aus_content
assert _zuordnungen_aus_content("nur Text, kein JSON") is None
assert _zuordnungen_aus_content(None) is None
def test_kategorisierer_nutzt_content_fallback_ohne_tool_calls():
# Antwort OHNE tool_calls, aber mit content-JSON -> Fallback greift.
from angebote.kategorisieren import OllamaKategorisierer
a = beispiel_angebot("Apfel")
inhalt = (
'{"zuordnungen":[{"id":"' + a.angebot_id
+ '","gruppe":"Obst & Gemüse","unsicher":false}]}'
)
payload = {"choices": [{"message": {"content": inhalt}}]}
sess = _FakeSession(payload)
kt = OllamaKategorisierer(modell="x", session=sess)
ergebnis = kategorisiere([a], kt)
assert ergebnis[0].gruppe == "Obst & Gemüse"

View file

@ -0,0 +1,94 @@
"""E2E-Test: die LLM-Konfiguration (Anbieter + Modell) überlebt einen Reload.
Echter Browser (Playwright) gegen einen frisch gestarteten Server. Prüft das
Verhalten, das im UI vorher fehlte: nach Umschalten auf Ollama und Neuladen darf
die UI NICHT auf den OpenRouter-Default zurückspringen.
Wird übersprungen, wenn Playwright/Chromium nicht installiert ist.
"""
import os
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
import pytest
sync_api = pytest.importorskip("playwright.sync_api")
from playwright.sync_api import sync_playwright # noqa: E402
REPO = Path(__file__).resolve().parents[1]
SRC = REPO / "src"
def _freier_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
@pytest.fixture(scope="module")
def server():
port = _freier_port()
env = {**os.environ, "PYTHONPATH": str(SRC)}
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "angebote.web:app",
"--port", str(port), "--log-level", "warning"],
cwd=str(SRC), env=env,
)
base = f"http://127.0.0.1:{port}"
try:
for _ in range(60):
try:
urllib.request.urlopen(base + "/", timeout=1)
break
except Exception:
time.sleep(0.25)
else:
raise RuntimeError("Server kam nicht hoch")
yield base
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except Exception:
proc.kill()
def _chromium_da() -> bool:
try:
with sync_playwright() as p:
b = p.chromium.launch()
b.close()
return True
except Exception:
return False
@pytest.mark.skipif(not _chromium_da(), reason="Chromium für Playwright nicht installiert")
def test_anbieter_persistiert_ueber_reload(server):
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(server)
page.wait_for_selector("#anbieter", state="attached")
# Konfig-Panel aufklappen (der Anbieter-Select liegt darin), dann
# auf Ollama umschalten -> löst change-Event + merkeWahl() aus.
page.evaluate("document.getElementById('config').open = true")
page.select_option("#anbieter", "ollama")
page.wait_for_timeout(1000)
# Reload -> init() muss die gemerkte Wahl wiederherstellen.
page.reload()
page.wait_for_selector("#anbieter", state="attached")
page.wait_for_timeout(1000)
wert = page.eval_on_selector("#anbieter", "el => el.value")
assert wert == "ollama", f"Anbieter nach Reload = {wert!r}, erwartet 'ollama'"
browser.close()