Ollama als lokaler Anbieter + gewähltes LLM dauerhaft sichtbar
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
59d7d916ef
commit
2e80f0d826
8 changed files with 335 additions and 32 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ OPENROUTER-KONFIG (separat) ============ -->
|
||||
<!-- ============ LLM-KONFIG (separat) ============ -->
|
||||
<details class="config" id="config">
|
||||
<summary>
|
||||
<span class="chev">▸</span>
|
||||
<span class="ico">⚙</span>
|
||||
<span>OpenRouter-Konfiguration</span>
|
||||
<span>LLM-Konfiguration</span>
|
||||
<span class="gewaehlt-chip" id="gewaehltChip" title="aktuell gewähltes Modell">—</span>
|
||||
<span class="gilt">gilt für Stufe 2</span>
|
||||
</summary>
|
||||
<div class="config-body">
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="anbieter">Anbieter</label>
|
||||
<select id="anbieter">
|
||||
<option value="openrouter">OpenRouter (Cloud)</option>
|
||||
<option value="ollama">Ollama (lokal)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="modell">Modell</label>
|
||||
<select id="modell"><option>lädt…</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field" id="suchfeld">
|
||||
<label for="modellsuche">Modell suchen</label>
|
||||
<input id="modellsuche" type="text" placeholder="z. B. qwen" />
|
||||
</div>
|
||||
|
|
@ -262,13 +275,13 @@
|
|||
<button id="refresh" class="ghost icon" title="Modell-Liste aktualisieren">↻ aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:var(--s4)">
|
||||
<div class="row" style="margin-top:var(--s4)" id="keyzeile">
|
||||
<div class="field keyrow">
|
||||
<label for="key">API-Key (bleibt lokal, nur an deinen Server)</label>
|
||||
<input id="key" type="password" placeholder="OPENROUTER_API_KEY — optional, falls nicht in der Server-Umgebung" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.</p>
|
||||
<p class="hint" id="konfighint">Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
|
@ -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 = "<option>lädt…</option>";
|
||||
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 = "<option value=''>(keine Treffer)</option>"; return; }
|
||||
if (!liste.length){
|
||||
sel.innerHTML = ab === "ollama"
|
||||
? "<option value=''>(kein lokales Modell — Ollama gestartet? 'ollama serve')</option>"
|
||||
: "<option value=''>(keine Treffer)</option>";
|
||||
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 = `<option value=''>Modelle nicht abrufbar</option>`;
|
||||
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
|
||||
? ` <span class="sep">·</span> kategorisiert mit <b>${esc(d.modell)}</b>${d.anbieter?` <span class="t-h">(${esc(d.anbieter)})</span>`:""}`
|
||||
: "";
|
||||
$("#summary").innerHTML =
|
||||
`Ort <b>${esc(d.ort_name || d.ort_plz)}</b> (PLZ ${esc(d.ort_plz)}) <span class="sep">·</span> <b>${d.anzahl}</b> Angebote <span class="sep">·</span>
|
||||
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher`;
|
||||
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> 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)
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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"]
|
||||
|
|
@ -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()) == []
|
||||
|
|
|
|||
|
|
@ -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) =========
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue