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:
parent
2e80f0d826
commit
2ffb89a6d2
4 changed files with 160 additions and 8 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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. <b>Achtung:</b> 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 = "<option>lädt…</option>";
|
||||
|
|
@ -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 = `<option value=''>Modelle nicht abrufbar</option>`;
|
||||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -166,3 +166,38 @@ def test_ollama_kategorisierer_parst_tool_calls_lokal():
|
|||
# OpenAI-kompatibel an den lokalen Endpoint adressiert:
|
||||
assert sess.calls[0]["url"].endswith("/chat/completions")
|
||||
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"
|
||||
94
tests/test_ui_persistenz.py
Normal file
94
tests/test_ui_persistenz.py
Normal 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()
|
||||
Loading…
Reference in a new issue