""" Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit und cached. Portiert aus Mission Control v1 (cookbook.py-Discover). Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable` ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen. """ import json import math import os import time from datetime import datetime 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 log = logging.getLogger(__name__) _FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2} def _categorize(repo_id: str) -> str: low = repo_id.lower() for cat in CATEGORIES: if any(k in low for k in cat["kw"]): return cat["role"] return "scout" def _fetch_author_models(author: str) -> list: url = (f"https://huggingface.co/api/models?author={author}" f"&filter=gguf&sort=downloads&direction=-1&limit=40") try: with httpx.Client(timeout=12.0) as c: data = c.get(url).json() return data if isinstance(data, list) else [] except Exception: log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True) return [] def _age_days(last_modified, now_ts: float) -> float: """Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre.""" if not last_modified: return 540.0 try: dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00")) return max((now_ts - dt.timestamp()) / 86400.0, 0.0) except Exception: return 540.0 def _score(m: dict, now_ts: float) -> float: """Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert: - Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst), - Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified), - Capability: mehr Parameter (log-skaliert), - Popularity: Downloads (log-skaliert). So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen.""" fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0 recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0) cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0 pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0 return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop def rank_runnable(models: list[dict]) -> list[dict]: """EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware. Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability + Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden" schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance).""" now_ts = time.time() return sorted( [m for m in models if m["fit"]["level"] != "too_tight"], key=lambda m: -_score(m, now_ts), ) def refresh_discover(ram_gb: float) -> dict: """Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE Quelle erreichbar war.""" raw, seen, ok = [], set(), 0 for author in TRUSTED_AUTHORS: models = _fetch_author_models(author) if models: ok += 1 for m in models: rid = m.get("id") if not rid or rid in seen: continue seen.add(rid) raw.append(m) if ok == 0 and not catalog.entries(): raise RuntimeError("Keine Quelle erreichbar.") by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES} for m in raw: rid = m["id"] low = rid.lower() if any(tok in low for tok in SKIP_TOKENS): continue role = _categorize(rid) params_b = extract_params_b(rid) quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid) tags = [str(t) for t in (m.get("tags") or [])] by_cat[role].append({ "name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid, "role": role, "params_b": params_b, "quant": quant, "tags": tags, "downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0), "lastModified": m.get("lastModified"), "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb), "caps": capabilities(name=rid, hf={"tags": tags}), }) cats = [] for c in CATEGORIES: 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": 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} try: DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(tmp, DISCOVER_CACHE_PATH) except Exception: log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True) return data def load_discover() -> dict | None: try: if DISCOVER_CACHE_PATH.exists(): return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8")) except Exception: log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True) return None def safe_discover(ram_gb: float) -> dict | None: """Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da.""" cached = load_discover() if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL): return cached try: return refresh_discover(ram_gb) except Exception: log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True) return cached