diff --git a/requirements.txt b/requirements.txt
index 969b271..3561675 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,4 +12,5 @@ uvicorn>=0.29
# Entwicklung/Tests:
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`)
diff --git a/src/angebote/web_static/index.html b/src/angebote/web_static/index.html
index b413901..38603e0 100644
--- a/src/angebote/web_static/index.html
+++ b/src/angebote/web_static/index.html
@@ -335,14 +335,27 @@ function setStatus(html){ $("#status").innerHTML = html; }
// ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) -------------------------
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 --
-// auch wenn das Panel zugeklappt ist.
+// auch wenn das Panel zugeklappt ist -- und merkt die Wahl.
function setGewaehlt(){
const sel = $("#modell");
const opt = sel.options[sel.selectedIndex];
const m = (sel.value && opt && !opt.disabled) ? sel.value : null;
const ab = anbieter() === "ollama" ? "Ollama (lokal)" : "OpenRouter";
$("#gewaehltChip").textContent = m ? `${m} · ${ab}` : `kein Modell · ${ab}`;
+ merkeWahl();
}
async function ladeModelle(q=""){
@@ -351,8 +364,8 @@ async function ladeModelle(q=""){
// Anbieter-spezifische UI: Ollama braucht weder Key noch Suche.
$("#keyzeile").style.display = ab === "ollama" ? "none" : "";
$("#suchfeld").style.display = ab === "ollama" ? "none" : "";
- $("#konfighint").textContent = ab === "ollama"
- ? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. Nur tool-fähige Modelle eignen sich."
+ $("#konfighint").innerHTML = ab === "ollama"
+ ? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. Achtung: kleine Modelle (3–9 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.";
const url = ab === "ollama" ? "/api/ollama-modelle" : ("/api/modelle?q=" + encodeURIComponent(q));
sel.innerHTML = "";
@@ -376,8 +389,15 @@ async function ladeModelle(q=""){
if (!m.tools) o.disabled = true;
sel.appendChild(o);
}
- const ok = [...sel.options].find(o => !o.disabled && o.value);
- if (ok) sel.value = ok.value; // erstes brauchbares (tool-fähiges) vorwählen
+ // gemerktes Modell für diesen Anbieter bevorzugen, sonst erstes brauchbares
+ 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();
} catch (e){
sel.innerHTML = ``;
@@ -610,7 +630,9 @@ for (const id of ["#fhaendler","#ftext","#fsicher"])
// Bookmarkbar: ?plz=… [&anbieter=…] [&modell=…] [&auto=1]
(async function init(){
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();
const m = _p.get("modell");
if (m){
diff --git a/tests/test_kategorisierer_anbieter.py b/tests/test_kategorisierer_anbieter.py
index 2df0e21..06566e2 100644
--- a/tests/test_kategorisierer_anbieter.py
+++ b/tests/test_kategorisierer_anbieter.py
@@ -165,4 +165,39 @@ def test_ollama_kategorisierer_parst_tool_calls_lokal():
assert ergebnis[0].gruppe == "Obst & Gemüse"
# OpenAI-kompatibel an den lokalen Endpoint adressiert:
assert sess.calls[0]["url"].endswith("/chat/completions")
- assert "localhost:11434" in sess.calls[0]["url"]
\ No newline at end of file
+ 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"
\ No newline at end of file
diff --git a/tests/test_ui_persistenz.py b/tests/test_ui_persistenz.py
new file mode 100644
index 0000000..1a5c5c4
--- /dev/null
+++ b/tests/test_ui_persistenz.py
@@ -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()