From 2ffb89a6d291b194c434aa2400a92e6353e46aef Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:34:34 +0200 Subject: [PATCH] Ollama-Konfig: Persistenz (localStorage) + E2E-Test, ehrliche Modell-Grenze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- requirements.txt | 3 +- src/angebote/web_static/index.html | 34 ++++++++-- tests/test_kategorisierer_anbieter.py | 37 ++++++++++- tests/test_ui_persistenz.py | 94 +++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 tests/test_ui_persistenz.py 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()