""" Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle? Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit (services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal. EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button. """ import psutil from services import budget, catalog, llamaswap from services.fit import evaluate_fit def _ram_gb() -> float: return psutil.virtual_memory().total / (1024 ** 3) def _capability_suit(role: str, caps: dict, name: str) -> float: """0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle. Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht.""" role = (role or "").lower() low = (name or "").lower() vision = bool(caps.get("vision")) coder = bool(caps.get("coder")) tools = caps.get("tools") != "no" if role == "vision": if not vision: return 0.0 # harte Anforderung return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni if role == "coder": return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung if role == "hermes": # Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht. if "hermes" in low: return 1.0 return 0.6 if tools else 0.1 if role == "fast": # Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref. return 1.0 if tools else 0.6 if role == "heavy": return 1.0 if role == "scout": return 0.9 if vision else 0.7 return 0.5 def _pref(role: str, params: float, tps: float) -> float: """0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit, 'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben).""" role = (role or "").lower() if role == "fast": speed = min(tps / 25.0, 1.0) size_ok = 1.0 if params <= 50 else 50.0 / params return speed * size_ok if role == "heavy": return min(params / 120.0, 1.0) if role == "hermes": return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 7–24B ideal als Hirn if role == "vision": return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt if role == "coder": return 0.5 + 0.5 * min(params / 80.0, 1.0) if role == "scout": return 1.0 if params <= 40 else 0.5 return 0.5 def _catalog_role_match(role: str, name: str) -> bool: """Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet? Dann ist es der prinzipien-konforme Pick → starker Bonus.""" meta = catalog.meta_for_name(name) return bool(meta and (meta.get("role") or "").lower() == (role or "").lower()) def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool, incomplete: bool, cat_match: bool) -> str: if incomplete: return "Download unvollständig" if role == "vision" and not caps.get("vision"): return "keine Vision-Fähigkeit" if role == "coder" and not caps.get("coder"): return "kein Coder-Modell" if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no": return "kein natives Tool-Calling" if not fits: return "passt nicht ins Budget (OOM)" bits = [] if cat_match: bits.append("Katalog-Pick ✓") if role == "vision": bits.append("Vision ✓") if role == "coder" and caps.get("coder"): bits.append("Coder ✓") if role == "hermes": bits.append("Hermes" if "hermes" in (name or "").lower() else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools")) if caps.get("moe"): bits.append("MoE") bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s") return " · ".join(bits) def recommend_for_role(role: str) -> dict: """Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter, passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI.""" role = (role or "").strip().lower() ram = _ram_gb() out = [] for m in llamaswap.list_models(): caps = m.get("capabilities") or {} params = budget.params_of_model(m) quant = m.get("quant") or "Q4_K_M" ctx = budget.setup_aware_ctx_for_model(m)["ctx"] fit = evaluate_fit(params, quant, ctx, ram, name=m["name"]) incomplete = bool(m.get("incomplete")) fits = (fit["level"] != "too_tight") and not incomplete tps = fit["tps"] or 0 suit = _capability_suit(role, caps, m["name"]) cat_match = _catalog_role_match(role, m["name"]) suitable = suit >= 0.5 and fits fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0) # Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit. score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term if not fits: score -= 5.0 out.append({ "name": m["name"], "current_role": m.get("role"), "params_b": round(params, 1), "quant": quant, "fit": fit, "suitable": suitable, "incomplete": incomplete, "score": round(score, 3), "reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match), }) out.sort(key=lambda x: -x["score"]) rec = next((o["name"] for o in out if o["suitable"]), None) for o in out: o["recommended"] = (o["name"] == rec) return {"role": role, "recommended": rec, "models": out}