diff --git a/backend/services/roles.py b/backend/services/roles.py index 8cce097..f86d982 100644 --- a/backend/services/roles.py +++ b/backend/services/roles.py @@ -9,7 +9,7 @@ und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx- import psutil -from services import budget, llamaswap +from services import budget, catalog, llamaswap from services.fit import evaluate_fit @@ -17,53 +17,86 @@ 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.""" +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" - moe = bool(caps.get("moe")) if role == "vision": - return 1.0 if vision else 0.0 # harte Anforderung + 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.45 # Coder bevorzugt, andere notfalls + return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung if role == "hermes": - # Agent-Hirn: natives Tool-Calling Pflicht; Hermes-Familie am robustesten. + # Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht. if "hermes" in low: return 1.0 - return 0.85 if tools else 0.15 + return 0.6 if tools else 0.1 if role == "fast": - # schnelles Alltags-Hirn: klein/MoE bevorzugt (niedrige aktive Params = Tempo). - return 1.0 if (moe or params <= 40) else 0.5 + # Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref. + return 1.0 if tools else 0.6 if role == "heavy": - # schweres Reasoning: Wissen = Gesamt-Params (groß bevorzugt). - return min(params / 70.0, 1.0) + return 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: +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 == "hermes" and caps.get("tools") == "no": + 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("Tools ✓" if caps.get("tools") != "no" else "ohne Tools") + 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") @@ -84,13 +117,14 @@ def recommend_for_role(role: str) -> dict: 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"]) + 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) - 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 + # 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 @@ -99,7 +133,7 @@ def recommend_for_role(role: str) -> dict: "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), + "reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match), }) out.sort(key=lambda x: -x["score"])