From c074d977ce24a73bb94444636292cd032d6c243d Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 27 Jun 2026 13:04:21 +0200 Subject: [PATCH] Feat: kuratierter Modell-Katalog (Cookbook) - korrekte Metadaten statt Namens-Raterei Inspiriert von Odysseus' Cookbook: backend/models_catalog.json mit echten Metadaten (total/active params, moe, generation) je Rolle. services/catalog.py: Laden, Name-Match, MoE-bewusstes Scoring (Wissen + Tempo via tps -> MoE-first auf der bandbreiten-Box), Fit. - discover.py: Empfehlung jetzt KATALOG-FIRST (kuratiert, korrekt), HF-Dynamik als Ergaenzung/Fallback. - maintenance.model_upgrades: Metadaten aus Katalog -> praezise Familie/Generation/Groesse + MoE-first (dense ersetzt MoE nur bei grossem Wissens-Sprung). Behebt Coder-Next=7B-Fehlschaetzung, Qwen2.5-VL-Generations-Downgrade, falsches dense-scout-Upgrade. - fit.py: MXFP4/FP8/AWQ in der Quant-Tabelle. Co-Authored-By: Claude Opus 4.8 --- backend/models_catalog.json | 60 +++++++++++++++++ backend/services/catalog.py | 115 ++++++++++++++++++++++++++++++++ backend/services/discover.py | 29 +++++--- backend/services/fit.py | 1 + backend/services/maintenance.py | 58 +++++++++++----- 5 files changed, 237 insertions(+), 26 deletions(-) create mode 100644 backend/models_catalog.json create mode 100644 backend/services/catalog.py diff --git a/backend/models_catalog.json b/backend/models_catalog.json new file mode 100644 index 0000000..d83e23f --- /dev/null +++ b/backend/models_catalog.json @@ -0,0 +1,60 @@ +{ + "_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.", + "version": "2026-06-27", + "models": [ + { + "role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF", + "family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true + }, + { + "role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF", + "family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false + }, + + { + "role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF", + "family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false + }, + { + "role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF", + "family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5, + "moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false + }, + + { + "role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF", + "family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false + }, + { + "role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", + "family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false + }, + + { + "role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF", + "family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8, + "moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true + }, + { + "role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF", + "family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2, + "moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true + }, + + { + "role": "scout", "name": "gemma-4-26B-A4B-it", "repo": "google/gemma-4-26B-A4B-it-GGUF", + "family": "gemma", "generation": 4.0, "total_params_b": 26, "active_params_b": 4, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true + }, + { + "role": "scout", "name": "gemma-4-31B-it", "repo": "google/gemma-4-31B-it-GGUF", + "family": "gemma", "generation": 4.0, "total_params_b": 31, "active_params_b": 31, + "moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true + } + ] +} diff --git a/backend/services/catalog.py b/backend/services/catalog.py new file mode 100644 index 0000000..09bba19 --- /dev/null +++ b/backend/services/catalog.py @@ -0,0 +1,115 @@ +""" +Kuratierter Modell-Katalog ("Cookbook", inspiriert von Odysseus): EINE Quelle der +Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) statt +Namens-Raterei. Macht Empfehlung + Upgrade-Erkennung präzise und MoE-bewusst +für die bandbreiten-limitierte Strix-Halo-Box. + +Daten: backend/models_catalog.json. Fällt sanft aus (leerer Katalog), wenn die +Datei fehlt → discover nutzt dann nur die HF-Dynamik. +""" + +import json +import math +import os +import re + +from services.fit import estimate_memory_gb, estimate_speed + +_CATALOG_PATH = os.path.join(os.path.dirname(__file__), "..", "models_catalog.json") +_cache: dict = {"mtime": 0.0, "models": []} + + +def _load() -> list[dict]: + try: + mt = os.path.getmtime(_CATALOG_PATH) + if mt != _cache["mtime"]: + with open(_CATALOG_PATH, encoding="utf-8") as f: + data = json.load(f) or {} + _cache.update(mtime=mt, models=[m for m in data.get("models", []) if m.get("name")]) + except (OSError, ValueError): + _cache.update(mtime=0.0, models=[]) + return _cache["models"] + + +def _norm(name: str) -> str: + """Vergleichs-Stamm: kleingeschrieben, Org-Prefix/Quant/GGUF/Split entfernt.""" + s = (name or "").lower().split("/")[-1] + s = re.sub(r"\.gguf$", "", s) + s = re.sub(r"-\d+-of-\d+$", "", s) + s = re.sub(r"[-_](ud-)?(i?q\d[\w]*|f16|bf16|fp16|f32|mxfp4)$", "", s) + return s.strip("-_ ") + + +def entries() -> list[dict]: + return list(_load()) + + +def entries_for_role(role: str) -> list[dict]: + return [e for e in _load() if e.get("role") == role] + + +def meta_for_name(name: str) -> dict | None: + """Katalog-Metadaten zu einem Modell(namen) — matcht lokalen Namen ODER HF-Repo.""" + n = _norm(name) + if not n: + return None + for e in _load(): + cand = {_norm(e.get("name", "")), _norm(e.get("repo", ""))} + if n in cand or any(c and (c in n or n in c) for c in cand): + return e + return None + + +def fit_of(e: dict, ram_gb: float) -> dict: + """Hardware-Fit eines Katalog-Eintrags (MoE-bewusst über active_params_b).""" + total = float(e.get("total_params_b") or 7) + active = float(e.get("active_params_b") or total) + quant = e.get("quant") or "Q4_K_M" + ctx = int(e.get("ctx") or 32768) + req_gb = estimate_memory_gb(total, quant, ctx) + tps = estimate_speed(req_gb, ram_gb, (active / total) if total else 1.0) + usable = max(ram_gb - 4.0, 0) + if req_gb > usable: + level, text = "too_tight", "Zu groß (OOM)" + elif req_gb > usable * 0.8: + level, text = "marginal", "Könnte knapp werden" + else: + level, text = "perfect", "Passt perfekt" + return {"level": level, "text": text, "req_gb": round(req_gb, 1), "tps": round(tps, 0)} + + +def stack_score(e: dict, ram_gb: float) -> float: + """Score für DIESE Hardware: muss passen, dann Wissen (total params) + Tempo + (tps — belohnt MoE durch niedrige aktive Params automatisch). Bandbreiten-Box + → MoE gewinnt bei vergleichbarem Wissen gegen dense.""" + fit = fit_of(e, ram_gb) + if fit["level"] == "too_tight": + return -100.0 + total = float(e.get("total_params_b") or 7) + fit_bonus = 3.0 if fit["level"] == "perfect" else 1.0 + knowledge = math.log2(total + 1) / 8.0 # ~0..1 (bis ~256B) + speed = min((fit["tps"] or 0) / 80.0, 1.0) # normalisiert; MoE = hohe tps + return fit_bonus + 1.2 * knowledge + 1.0 * speed + + +def to_model_dict(e: dict, ram_gb: float) -> dict: + """Katalog-Eintrag → discover-kompatibles Modell-Dict (echte Metadaten).""" + total = float(e.get("total_params_b") or 7) + active = e.get("active_params_b") + repo = e.get("repo") or e.get("name") + role = e.get("role") + caps = { + "moe": bool(e.get("moe")), "active_b": active, + "tools": "yes" if e.get("tools") else "no", + "vision": bool(e.get("vision")), "coder": role == "coder", + "reasoning": role == "heavy", "embedding": False, + "ctx": e.get("ctx"), "params_b": total, "arch": e.get("family"), + } + return { + "name": e.get("name"), "author": repo.split("/")[0] if "/" in repo else "catalog", + "repo": repo, "role": role, "params_b": total, "active_b": active, + "moe": bool(e.get("moe")), "generation": e.get("generation"), + "family": e.get("family"), "quant": e.get("quant") or "Q4_K_M", + "tags": ["catalog"], "downloads": 0, "fit": fit_of(e, ram_gb), + "optimal_ctx": int(e.get("ctx") or 32768), "caps": caps, "curated": True, + } diff --git a/backend/services/discover.py b/backend/services/discover.py index fdce8db..ad56131 100644 --- a/backend/services/discover.py +++ b/backend/services/discover.py @@ -19,6 +19,7 @@ import httpx import logging from config import DISCOVER_CACHE_PATH, DISCOVER_TTL +from services import catalog from services.caps import capabilities from services.fit import evaluate_fit, extract_params_b, max_ctx_for from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS @@ -100,7 +101,7 @@ def refresh_discover(ram_gb: float) -> dict: continue seen.add(rid) raw.append(m) - if ok == 0: + if ok == 0 and not catalog.entries(): raise RuntimeError("Keine Quelle erreichbar.") by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES} @@ -125,15 +126,25 @@ def refresh_discover(ram_gb: float) -> dict: cats = [] for c in CATEGORIES: - items = by_cat[c["role"]] - ranked = rank_runnable(items) - # Top 4 je Kategorie = die best-gerankten (fähigstes-was-passt zuerst). - top = ranked[:4] - if top: + role = c["role"] + # 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) — + # macht die Empfehlung präzise statt Namens-Raterei. + cat_entries = sorted(catalog.entries_for_role(role), + key=lambda e: -catalog.stack_score(e, ram_gb)) + cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries) + if m["fit"]["level"] != "too_tight"] + # 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert. + hf_ranked = rank_runnable(by_cat[role]) + seen = {catalog._norm(m["repo"]) for m in cat_models} + extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen] + combined = cat_models + extra + if combined: cats.append({ - "role": c["role"], "title": c["title"], "icon": c["icon"], - "models": top, - "recommended": ranked[0]["repo"] if ranked else None, + "role": role, "title": c["title"], "icon": c["icon"], + "models": combined[:6], + # Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle. + "recommended": (cat_models[0]["repo"] if cat_models + else (hf_ranked[0]["repo"] if hf_ranked else None)), }) data = {"updated": time.time(), "categories": cats} diff --git a/backend/services/fit.py b/backend/services/fit.py index ddb6509..ba388ab 100644 --- a/backend/services/fit.py +++ b/backend/services/fit.py @@ -11,6 +11,7 @@ QUANT_BYTES_PER_PARAM = { "Q4_0": 0.50, "Q4_1": 0.55, "Q4_K_S": 0.50, "Q4_K_M": 0.55, "Q5_0": 0.62, "Q5_1": 0.68, "Q5_K_S": 0.62, "Q5_K_M": 0.65, "Q6_K": 0.75, "Q8_0": 1.00, "F16": 2.00, "BF16": 2.00, + "MXFP4": 0.55, "FP8": 1.05, "AWQ": 0.55, } diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index e55b270..ce2b6ee 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -16,7 +16,7 @@ import httpx import psutil from config import ANYTHINGLLM_REPO, ANYTHINGLLM_URL, HERMES_AGENT_REPO -from services import discover, jobengine, llamaswap, system +from services import catalog, discover, jobengine, llamaswap, system # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). SYSTEM_SERVICES = {"llama-swap"} @@ -185,11 +185,27 @@ def _gen_key(name: str): return None +def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict: + """Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog, + sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik.""" + cm = catalog.meta_for_name(name) + if cm: + return {"family": cm.get("family"), "gen": cm.get("generation"), + "total": float(cm.get("total_params_b") or 0), + "active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))} + d = model_dict or {} + g = _gen_key(name) + total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0) + return {"family": (d.get("family") or (g[0] if g else None)), + "gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)), + "total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))} + + def model_upgrades() -> list[dict]: - """Je Rolle ein ECHTES Upgrade-Vorschlag — nur wenn die Empfehlung wirklich besser ist: - gleiche Familie UND (neuere Generation ODER größer in gleicher Generation). Verhindert - Generations-Downgrades (Qwen2.5-VL über Qwen3-VL), Größen-Downgrades (Coder-Next → 30B) - und Fremd-Familien-Swaps (gpt-oss als „Upgrade" von Qwen).""" + """Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist: + gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade + (MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens- + Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei.""" disc = discover.safe_discover(_ram_gb()) if not disc: return [] @@ -203,28 +219,36 @@ def model_upgrades() -> list[dict]: for c in disc.get("categories", []): role = c["role"] im = inst_by_role.get(role) - if im is None: # nur Rollen mit bereits installiertem Modell + if im is None: continue rec = c.get("recommended") if not rec: continue rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None) - rec_params = float((rec_model or {}).get("params_b") or 0.0) - inst_params = _params_of(im) - ig, rg = _gen_key(im["name"]), _gen_key(rec) - # Upgrade nur bei GLEICHER, erkennbarer Familie: - if not (ig and rg) or ig[0] != rg[0]: - continue # Fremd-Familie / unbekannt → kein „Upgrade" - if rg[1] < ig[1] - 1e-6: - continue # ältere Generation → niemals - if abs(rg[1] - ig[1]) < 1e-6 and rec_params and rec_params < inst_params * 1.05: - continue # gleiche Gen, nicht (deutlich) größer + i = _meta(im["name"], im=im) + r = _meta(rec, model_dict=rec_model) + + if not i["family"] or not r["family"] or i["family"] != r["family"]: + continue # andere/unbekannte Familie → kein Upgrade + if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6: + continue # ältere Generation → niemals + same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6) + if same_gen: + if r["total"] and i["total"] and r["total"] < i["total"] * 1.05: + continue # gleiche Gen, nicht größer → kein Upgrade + # MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung + if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5: + continue + # Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn + ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"]) + if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3: + continue base = rec.split("/")[-1].lower() stem = base[:-5] if base.endswith("-gguf") else base if base in cmds or (stem and stem in cmds): - continue # schon installiert + continue # schon installiert out.append({"role": role, "title": c["title"], "repo": rec}) return out