From 2e80f0d826c6604ddf378359556316b82b7d8fc4 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:54:32 +0200 Subject: [PATCH] =?UTF-8?q?Ollama=20als=20lokaler=20Anbieter=20+=20gew?= =?UTF-8?q?=C3=A4hltes=20LLM=20dauerhaft=20sichtbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OllamaKategorisierer (lokaler OpenAI-kompatibler Endpoint, kein Key/Netz), baue_kategorisierer('ollama'), Default-Ollama-Modell. - modelle.lade_ollama_modelle: /api/tags + /api/show (Tool-Fähigkeit), nur tool-fähige taugen; leere Liste wenn Ollama aus. - web: /api/ollama-modelle, Anbieter im Kategorisier-Flow + Cache-Key, Modell+Anbieter im Ergebnis (als_struktur). - UI: Anbieter-Umschalter (OpenRouter/Ollama), gewähltes Modell als Chip im Konfig-Kopf (auch zugeklappt) + 'kategorisiert mit … (anbieter)' im Ergebnis, bookmarkbarer ?modell/?anbieter/?auto-Start. - content-JSON-Fallback fürs Tool-Parsing (manche lokale Modelle liefern die Antwort als Text-JSON). +6 Tests (53 gesamt). Ehrlich: lokal installierte Modelle (qwen2.5-coder/gemma4/qwen3.5) liefern kein brauchbares Tool-Calling -> Ergebnis dort 'Sonstiges/unsicher' (ehrlich markiert, nicht geraten). Cloud-Default deepseek-v4-flash voll verifiziert (1903 Angebote, modellstabil). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/angebote/kategorisieren.py | 92 ++++++++++++++++++++++--- src/angebote/modelle.py | 52 ++++++++++++++ src/angebote/uebersicht.py | 10 ++- src/angebote/web.py | 24 ++++++- src/angebote/web_static/index.html | 98 +++++++++++++++++++++------ tests/test_kategorisierer_anbieter.py | 26 ++++++- tests/test_modelle.py | 45 ++++++++++++ tests/test_web.py | 20 ++++++ 8 files changed, 335 insertions(+), 32 deletions(-) diff --git a/src/angebote/kategorisieren.py b/src/angebote/kategorisieren.py index 1a05564..e621965 100644 --- a/src/angebote/kategorisieren.py +++ b/src/angebote/kategorisieren.py @@ -140,6 +140,45 @@ def _user_text(posten: list[dict]) -> str: ) +def _finde_zuordnungen(obj): + """Sucht rekursiv ein 'zuordnungen'-Array in einem geparsten JSON-Objekt.""" + if isinstance(obj, dict): + if isinstance(obj.get("zuordnungen"), list): + return obj["zuordnungen"] + for v in obj.values(): + treffer = _finde_zuordnungen(v) + if treffer: + return treffer + elif isinstance(obj, list): + for v in obj: + treffer = _finde_zuordnungen(v) + if treffer: + return treffer + return None + + +def _zuordnungen_aus_content(content) -> list | None: + """Extrahiert ein 'zuordnungen'-Array aus einem content-String (Fallback). + + Manche lokale Modelle verpacken die Tool-Antwort als (ggf. in ```json + eingefasstes) JSON im Text statt als echte tool_calls. Wir lesen das defensiv + aus -- finden wir nichts Verwertbares, geben wir None zurück (kein Raten). + """ + if not isinstance(content, str) or "{" not in content: + return None + import re + + for roh in re.findall(r"\{.*\}", content, re.DOTALL): + try: + obj = json.loads(roh) + except json.JSONDecodeError: + continue + treffer = _finde_zuordnungen(obj) + if treffer: + return treffer + return None + + def _wartezeit(antwort, versuch: int, basis: float) -> float: """Backoff: Retry-After respektieren, sonst exponentiell ab max(basis, 2s).""" try: @@ -319,23 +358,58 @@ class OpenRouterKategorisierer: vorschlag="Key/Modell/Guthaben prüfen oder --no-llm verwenden", ) try: - aufrufe = daten["choices"][0]["message"]["tool_calls"] - args = json.loads(aufrufe[0]["function"]["arguments"]) - return list(args.get("zuordnungen", [])) - except (KeyError, IndexError, TypeError, json.JSONDecodeError): - # Modell hat das Tool nicht (sauber) aufgerufen -> leer; die - # kategorisiere()-Logik markiert die Posten dann als unsicher. + nachricht = daten["choices"][0]["message"] + except (KeyError, IndexError, TypeError): return [] + # 1) Sauberer Tool-Call (OpenRouter / gute Modelle). + try: + args = json.loads(nachricht["tool_calls"][0]["function"]["arguments"]) + z = args.get("zuordnungen") + if z is not None: + return list(z) + except (KeyError, IndexError, TypeError, json.JSONDecodeError): + pass + # 2) Fallback: manche (lokale Ollama-) Modelle schreiben die Tool-Antwort + # als content-JSON statt als tool_calls. Defensiv auslesen, nicht raten. + z = _zuordnungen_aus_content(nachricht.get("content")) + return z if z else [] + + +class OllamaKategorisierer(OpenRouterKategorisierer): + """Lokaler LLM über Ollama (OpenAI-kompatibler Endpoint /v1/chat/completions). + + Erbt die komplette Tool-Calling-Logik vom OpenRouter-Kategorisierer -- Ollama + spricht dasselbe OpenAI-Schema. Kein Key (der Dummy-Bearer wird ignoriert), + kein Netz, keine Drosselung. Nur tool-fähige lokale Modelle (z. B. qwen3.5, + qwen2.5-coder, gemma4) eignen sich; andere liefern keine sauberen tool_calls. + """ + + def __init__( + self, + *, + modell: str, + base_url: str = "http://localhost:11434/v1", + session=None, + ) -> None: + super().__init__( + api_key="ollama", # Dummy -- der lokale Server ignoriert den Bearer. + modell=modell, + base_url=base_url, + session=session, + mindest_abstand_s=0.0, # lokal: keine Rate-Limits + ) # Default je Anbieter. Für OpenRouter ein günstiges, aber verlässliches paid- # Modell: deepseek-v4-flash macht sauberes Tool-Calling, ist über mehrere # Batches konsistent und kostet nur ~1-2 Cent pro vollem Lauf (1903 Angebote). -# Free-Modelle sind bei OpenRouter oft hart gedrosselt; mit `--modell` (CLI) bzw. -# der Modellauswahl in der Web-UI lässt sich jederzeit ein anderes wählen. +# Free-Modelle sind bei OpenRouter oft hart gedrosselt. Für Ollama ein gängiges +# tool-fähiges lokales Modell als Fallback. Mit `--modell` (CLI) bzw. der +# Modellauswahl in der Web-UI lässt sich jederzeit ein anderes wählen. _DEFAULT_MODELLE = { "anthropic": "claude-sonnet-4-6", "openrouter": "deepseek/deepseek-v4-flash", + "ollama": "qwen3.5:latest", } @@ -350,6 +424,8 @@ def baue_kategorisierer( modell = modell or _DEFAULT_MODELLE.get(anbieter) if anbieter == "openrouter": return OpenRouterKategorisierer(modell=modell, api_key=api_key) + if anbieter == "ollama": + return OllamaKategorisierer(modell=modell) if anbieter == "anthropic": return AnthropicKategorisierer(modell=modell) raise AbbruchFehler( diff --git a/src/angebote/modelle.py b/src/angebote/modelle.py index dd99acc..44513da 100644 --- a/src/angebote/modelle.py +++ b/src/angebote/modelle.py @@ -113,3 +113,55 @@ def suche( if nur_tools: res = [m for m in res if m.tools] return sorted(res, key=_rang) + + +# --- Ollama (lokale LLMs) ---------------------------------------------------- + +OLLAMA_BASIS = "http://localhost:11434" + + +def _ollama_kann_tools(sess, name: str) -> bool: + """Liest die Capabilities eines lokalen Modells (/api/show) -- 'tools'?""" + try: + r = sess.post(OLLAMA_BASIS + "/api/show", json={"model": name}, timeout=8) + r.raise_for_status() + return "tools" in (r.json().get("capabilities") or []) + except Exception: + return False # nicht belegbar -> konservativ als nicht tool-fähig + + +def lade_ollama_modelle(session=None) -> list[ModellInfo]: + """Lokal installierte Ollama-Modelle (/api/tags), mit Tool-Fähigkeit. + + `frei=True` (lokal = ohne Kosten). `tools` aus /api/show -- nur tool-fähige + Modelle taugen für die Kategorisierung. Läuft Ollama nicht, kommt eine LEERE + Liste zurück (kein Fehler -- der Anbieter ist optional), nicht geraten. + """ + sess = session + if sess is None: + import requests + + sess = requests + try: + r = sess.get(OLLAMA_BASIS + "/api/tags", timeout=5) + r.raise_for_status() + eintraege = r.json().get("models", []) + except Exception: + return [] + + out: list[ModellInfo] = [] + for m in eintraege: + name = m.get("name") or m.get("model") or "" + if not name: + continue + out.append( + ModellInfo( + id=name, + name=name, + context=None, + frei=True, + tools=_ollama_kann_tools(sess, name), + ) + ) + # tool-fähige zuerst, dann alphabetisch -- die brauchbaren oben. + return sorted(out, key=lambda mi: (not mi.tools, mi.id)) diff --git a/src/angebote/uebersicht.py b/src/angebote/uebersicht.py index 7b5484b..d62f392 100644 --- a/src/angebote/uebersicht.py +++ b/src/angebote/uebersicht.py @@ -44,16 +44,24 @@ def _angebot_dict(ka: KategorisiertesAngebot) -> dict: def als_struktur( fetch: FetchErgebnis, kategorisiert: list[KategorisiertesAngebot], + *, + modell: str | None = None, + anbieter: str | None = None, ) -> dict: """Strukturierte Ausgabe für die Web-UI -- dieselben belegten Felder wie der Markdown-Renderer, nur als JSON-fähiges dict. Leere Gruppen bleiben enthalten - (als leere Liste) -- kein Weglassen, kein Auffüllen.""" + (als leere Liste) -- kein Weglassen, kein Auffüllen. + + `modell`/`anbieter` belegen, WOMIT kategorisiert wurde -- damit in der UI + nachvollziehbar bleibt, welches LLM die Einordnung vorgenommen hat.""" gruppen = gruppieren(kategorisiert) return { "ort_plz": fetch.ort_plz, "ort_name": fetch.ort_name, "anzahl": len(kategorisiert), "unsicher": sum(1 for k in kategorisiert if k.unsicher), + "modell": modell, + "anbieter": anbieter, "quellen": list(fetch.abgedeckte_quellen), "haendler": list(fetch.gesehene_haendler), "hinweise": list(fetch.hinweise), diff --git a/src/angebote/web.py b/src/angebote/web.py index 7029d74..2ddd52b 100644 --- a/src/angebote/web.py +++ b/src/angebote/web.py @@ -62,6 +62,17 @@ def api_modelle(q: str = "") -> list[dict]: ] +@app.get("/api/ollama-modelle") +def api_ollama_modelle() -> list[dict]: + """Lokal installierte Ollama-Modelle. Leere Liste, wenn Ollama nicht läuft.""" + from .modelle import lade_ollama_modelle + + return [ + {"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context} + for m in lade_ollama_modelle() + ] + + # === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) ========= @@ -160,7 +171,9 @@ def api_kategorisieren(req: dict) -> dict: job_id = uuid.uuid4().hex[:12] # Cache-Treffer? Dann sofort als fertiger Job ausliefern, kein neuer Lauf. - treffer = _ergebnis_cache.get((plz, modell)) + # Anbieter ist Teil des Schlüssels -- dasselbe Modell-Kürzel kann je Anbieter + # etwas anderes bedeuten. + treffer = _ergebnis_cache.get((plz, anbieter, modell)) if treffer is not None: with _jobs_lock: _jobs[job_id] = { @@ -207,6 +220,9 @@ def _run_kategorisieren(job_id, plz, fetch, modell, anbieter, key) -> None: from .uebersicht import als_struktur kt = baue_kategorisierer(anbieter, modell, api_key=key) + # Tatsächlich genutztes Modell (Default je Anbieter aufgelöst) -- für die + # sichtbare Herkunft im Ergebnis. + modell_genutzt = getattr(kt, "_modell", modell) def fort(done, total): job["done"] = done @@ -214,9 +230,11 @@ def _run_kategorisieren(job_id, plz, fetch, modell, anbieter, key) -> None: kat = kategorisiere(list(fetch.angebote), kt, fortschritt=fort) - job["ergebnis"] = als_struktur(fetch, kat) + job["ergebnis"] = als_struktur( + fetch, kat, modell=modell_genutzt, anbieter=anbieter + ) job["status"] = "fertig" - _ergebnis_cache[(plz, modell)] = job["ergebnis"] + _ergebnis_cache[(plz, anbieter, modell)] = job["ergebnis"] except AbbruchFehler as e: job["status"] = "fehler" job["fehler"] = e.als_text() diff --git a/src/angebote/web_static/index.html b/src/angebote/web_static/index.html index 96089c7..b413901 100644 --- a/src/angebote/web_static/index.html +++ b/src/angebote/web_static/index.html @@ -140,7 +140,12 @@ .config > summary .chev { transition:transform .2s; color:var(--muted); } .config[open] > summary .chev { transform:rotate(90deg); } .config > summary .ico { font-size:14px; } - .config > summary .gilt { margin-left:auto; font-weight:500; font-size:12px; color:var(--muted); } + .config > summary .gewaehlt-chip { + margin-left:auto; font-size:12px; font-weight:600; color:var(--brand-d); + background:var(--brand-weak); border:1px solid #cfe3d9; border-radius:999px; + padding:2px 11px; max-width:46ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; + } + .config > summary .gilt { margin-left:var(--s3); font-weight:500; font-size:12px; color:var(--muted); } .config-body { padding:0 var(--s6) var(--s6); border-top:1px solid var(--line-2); padding-top:var(--s4); } .keyrow { width:100%; max-width:560px; } .keyrow input { width:100%; } @@ -239,21 +244,29 @@ - +
- OpenRouter-Konfiguration + LLM-Konfiguration + gilt für Stufe 2
+
+ + +
-
+
@@ -262,13 +275,13 @@
-
+
-

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.

@@ -319,26 +332,56 @@ function preisFmt(p){ return p!=null ? p.toLocaleString("de-DE",{minimumFraction function setRohStatus(html){ $("#rohstatus").innerHTML = html; } function setStatus(html){ $("#status").innerHTML = html; } -// ----- Modelle (OpenRouter-Konfig) ------------------------------------------ +// ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) ------------------------- +function anbieter(){ return $("#anbieter").value; } + +// Hält das dauerhaft sichtbare "gewähltes Modell"-Chip im Konfig-Kopf aktuell -- +// auch wenn das Panel zugeklappt ist. +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}`; +} + async function ladeModelle(q=""){ const sel = $("#modell"); + const ab = anbieter(); + // 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." + : "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 = ""; try { - const r = await fetch("/api/modelle?q=" + encodeURIComponent(q)); + const r = await fetch(url); if (!r.ok) throw new Error((await r.json()).detail || r.status); const liste = await r.json(); sel.innerHTML = ""; - if (!liste.length){ sel.innerHTML = ""; return; } + if (!liste.length){ + sel.innerHTML = ab === "ollama" + ? "" + : ""; + setGewaehlt(); return; + } for (const m of liste){ const o = document.createElement("option"); o.value = m.id; - const tag = m.frei ? "FREE" : "paid"; + const tag = ab === "ollama" ? "lokal" : (m.frei ? "FREE" : "paid"); const tools = m.tools ? "" : " ⚠ kein tool-calling"; o.textContent = `${m.id} [${tag}]${tools}`; 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 + setGewaehlt(); } catch (e){ sel.innerHTML = ``; + setGewaehlt(); } } @@ -438,8 +481,8 @@ async function kategorisiere(){ const r = await fetch("/api/kategorisieren", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ - plz, modell: $("#modell").value, anbieter:"openrouter", - key: $("#key").value || undefined + plz, modell: $("#modell").value, anbieter: anbieter(), + key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined }) }); const d = await r.json(); @@ -477,9 +520,12 @@ async function kategorisiere(){ function render(){ const d = DATEN; $("#result").hidden = false; + const modellTxt = d.modell + ? ` · kategorisiert mit ${esc(d.modell)}${d.anbieter?` (${esc(d.anbieter)})`:""}` + : ""; $("#summary").innerHTML = `Ort ${esc(d.ort_name || d.ort_plz)} (PLZ ${esc(d.ort_plz)}) · ${d.anzahl} Angebote · - Quellen: ${d.quellen.map(esc).join(", ") || "—"} · ${d.unsicher} unsicher`; + Quellen: ${d.quellen.map(esc).join(", ") || "—"} · ${d.unsicher} unsicher${modellTxt}`; const fh = $("#fhaendler"); const aktuell = fh.value; @@ -548,6 +594,8 @@ $("#rohholen").onclick = holeRohdaten; $("#kategorisieren").onclick = kategorisiere; $("#refresh").onclick = () => ladeModelle($("#modellsuche").value.trim()); $("#modellsuche").addEventListener("keydown", e => { if (e.key==="Enter") ladeModelle(e.target.value.trim()); }); +$("#anbieter").addEventListener("change", () => ladeModelle()); // Anbieter umschalten -> Liste neu +$("#modell").addEventListener("change", setGewaehlt); // Auswahl -> sichtbares Chip aktualisieren $("#plz").addEventListener("keydown", e => { if (e.key==="Enter") holeRohdaten(); }); let _plzTimer; $("#plz").addEventListener("input", () => { @@ -559,12 +607,24 @@ for (const id of ["#fhaendler","#ftext","#fsicher"]) $(id).addEventListener("input", () => DATEN && zeichneGruppen()); // ----- Init ------------------------------------------------------------------ -ladeModelle(); - -// Auto: ?plz=… vorbelegen (überschreibt den Default), dann GENAU EINMAL prüfen. -const _p = new URLSearchParams(location.search); -if (_p.get("plz")) $("#plz").value = _p.get("plz").trim(); -pruefeRohstand($("#plz").value.trim()); +// Bookmarkbar: ?plz=… [&anbieter=…] [&modell=…] [&auto=1] +(async function init(){ + const _p = new URLSearchParams(location.search); + if (_p.get("anbieter")) $("#anbieter").value = _p.get("anbieter"); + await ladeModelle(); + const m = _p.get("modell"); + if (m){ + const sel = $("#modell"); + if (![...sel.options].some(o => o.value === m)){ // auch nicht-gelistetes zulassen + const o = document.createElement("option"); o.value = o.textContent = m; + sel.insertBefore(o, sel.firstChild); + } + sel.value = m; setGewaehlt(); + } + if (_p.get("plz")) $("#plz").value = _p.get("plz").trim(); + await pruefeRohstand($("#plz").value.trim()); + if (_p.get("auto") && ROHDA) kategorisiere(); // Stufe 2 automatisch (wenn Rohdaten da) +})(); diff --git a/tests/test_kategorisierer_anbieter.py b/tests/test_kategorisierer_anbieter.py index 3eaac2f..2df0e21 100644 --- a/tests/test_kategorisierer_anbieter.py +++ b/tests/test_kategorisierer_anbieter.py @@ -141,4 +141,28 @@ def test_openrouter_429_erschoepft_bricht_als_ratelimit_ab(): with pytest.raises(AbbruchFehler) as exc: kat.klassifiziere([{"id": "x", "titel": "Apfel"}]) assert "Rate-Limit" in exc.value.schwelle - assert seq.calls == 3 \ No newline at end of file + assert seq.calls == 3 + + +def test_baue_kategorisierer_ollama_ohne_key(): + from angebote.kategorisieren import OllamaKategorisierer, baue_kategorisierer + + # Ollama braucht KEINEN Key -- darf also nicht abbrechen. + kt = baue_kategorisierer("ollama", "qwen3.5:latest") + assert isinstance(kt, OllamaKategorisierer) + + +def test_ollama_kategorisierer_parst_tool_calls_lokal(): + from angebote.kategorisieren import OllamaKategorisierer + + a = beispiel_angebot("Apfel") + payload = _openrouter_payload( + [{"id": a.angebot_id, "gruppe": "Obst & Gemüse", "unsicher": False}] + ) + sess = _FakeSession(payload) + kt = OllamaKategorisierer(modell="qwen3.5:latest", session=sess) + ergebnis = kategorisiere([a], kt) + 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 diff --git a/tests/test_modelle.py b/tests/test_modelle.py index 746b9fb..af82e14 100644 --- a/tests/test_modelle.py +++ b/tests/test_modelle.py @@ -134,3 +134,48 @@ def test_picker_quit_gibt_none(): ausgabe=lambda s: None, ) assert gewaehlt is None + + +# --- Ollama (lokale Modelle) ------------------------------------------------- + + +class _OllamaSession: + """Fake für /api/tags (Liste) + /api/show (Capabilities).""" + + def __init__(self, namen, caps): + self._namen = namen + self._caps = caps + + def get(self, url, timeout=None): + return _Resp({"models": [{"name": n} for n in self._namen]}) + + def post(self, url, json=None, timeout=None): + name = (json or {}).get("model") + return _Resp({"capabilities": self._caps.get(name, [])}) + + +def test_lade_ollama_modelle_tool_faehige_zuerst(): + from angebote.modelle import lade_ollama_modelle + + sess = _OllamaSession( + ["gemma3:latest", "qwen3.5:latest", "nomic-embed:latest"], + { + "qwen3.5:latest": ["completion", "tools"], + "gemma3:latest": ["completion", "vision"], + "nomic-embed:latest": ["embedding"], + }, + ) + modelle = lade_ollama_modelle(session=sess) + assert all(m.frei for m in modelle) # lokal = frei + assert modelle[0].id == "qwen3.5:latest" and modelle[0].tools is True + assert any(m.id == "gemma3:latest" and not m.tools for m in modelle) + + +def test_lade_ollama_modelle_leer_wenn_server_aus(): + from angebote.modelle import lade_ollama_modelle + + class _Down: + def get(self, *a, **k): + raise OSError("connection refused") + + assert lade_ollama_modelle(session=_Down()) == [] diff --git a/tests/test_web.py b/tests/test_web.py index 3d30a2e..431e8b2 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -75,6 +75,26 @@ def test_api_status_unbekannt_ist_404(): assert r.status_code == 404 +def test_als_struktur_fuehrt_modell_und_anbieter(): + a = beispiel_angebot("Butter") + kat = [KategorisiertesAngebot(a, "Molkereiprodukte & Eier", unsicher=False)] + s = als_struktur(_fetch_mit([a]), kat, modell="qwen3.5:latest", anbieter="ollama") + assert s["modell"] == "qwen3.5:latest" + assert s["anbieter"] == "ollama" + + +def test_api_ollama_modelle(monkeypatch): + fake = [ModellInfo("qwen3.5:latest", "qwen3.5:latest", None, True, True)] + monkeypatch.setattr( + "angebote.modelle.lade_ollama_modelle", lambda session=None: fake + ) + client = TestClient(web.app) + r = client.get("/api/ollama-modelle") + assert r.status_code == 200 + daten = r.json() + assert daten[0]["id"] == "qwen3.5:latest" and daten[0]["tools"] is True + + # === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) =========