Feat: Rollen-Empfehlung - bestes installiertes Modell je Rolle (Auto-Pick)

Analog zum Auto-ctx-Button: das Rollen-Zuweisungs-Modal empfiehlt jetzt, welches
INSTALLIERTE Modell am besten auf die Rolle passt - capability-getrieben (Vision/Coder/
Tools/MoE aus services.caps) + setup-bewusster Fit (services.budget, gleiche Mathematik
wie Install-Automatik & Auto-ctx).

- services/roles.py: recommend_for_role() rankt installierte Modelle (Eignung + Fit + Tempo
  + Wissen); harte Anforderungen (Vision braucht Vision, Hirn braucht Tools) schliessen aus.
- GET /api/roles/{role}/recommend
- Cockpit-Modal: »Auto: <Modell>«-Button im Header, »Empfohlen«-Badge, Sortierung nach Score,
  pro Zeile Fit + Begruendung (~t/s); ungeeignete gedimmt mit Klartext-Grund.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 14:37:37 +02:00
parent 1e011714dd
commit 763e634dfd
8 changed files with 281 additions and 108 deletions
+109
View File
@@ -0,0 +1,109 @@
"""
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, llamaswap
from services.fit import evaluate_fit
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _suitability(role: str, caps: dict, params: float, name: str) -> float:
"""0..1 — wie gut passt die Capability eines Modells zur Rolle. Harte Anforderungen
(Vision braucht Vision) geben 0 bei Nichterfüllung; weiche Präferenzen skalieren."""
role = (role or "").lower()
low = (name or "").lower()
vision = bool(caps.get("vision"))
coder = bool(caps.get("coder"))
tools = caps.get("tools") != "no"
moe = bool(caps.get("moe"))
if role == "vision":
return 1.0 if vision else 0.0 # harte Anforderung
if role == "coder":
return 1.0 if coder else 0.45 # Coder bevorzugt, andere notfalls
if role == "hermes":
# Agent-Hirn: natives Tool-Calling Pflicht; Hermes-Familie am robustesten.
if "hermes" in low:
return 1.0
return 0.85 if tools else 0.15
if role == "fast":
# schnelles Alltags-Hirn: klein/MoE bevorzugt (niedrige aktive Params = Tempo).
return 1.0 if (moe or params <= 40) else 0.5
if role == "heavy":
# schweres Reasoning: Wissen = Gesamt-Params (groß bevorzugt).
return min(params / 70.0, 1.0)
if role == "scout":
# Multimodal-Allrounder: Vision ein Plus, sonst solide Basis.
return 0.9 if vision else 0.7
return 0.5
def _reason(role: str, caps: dict, fit: dict, suit: float, fits: bool, incomplete: bool) -> str:
if incomplete:
return "Download unvollständig"
if role == "vision" and not caps.get("vision"):
return "keine Vision-Fähigkeit"
if role == "hermes" and caps.get("tools") == "no":
return "kein natives Tool-Calling"
if not fits:
return "passt nicht ins Budget (OOM)"
bits = []
if role == "vision":
bits.append("Vision ✓")
if role == "coder" and caps.get("coder"):
bits.append("Coder ✓")
if role == "hermes":
bits.append("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
suit = _suitability(role, caps, params, m["name"])
suitable = suit >= 0.5 and fits
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
score = (2.0 * suit) + fit_term \
+ min((fit["tps"] or 0) / 80.0, 1.0) * 0.5 \
+ min(params / 120.0, 1.0) * 0.5
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, fit, suit, fits, incomplete),
})
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}