modelle: Aufraeumen auf Knopfdruck, geteilte Gewichte bleiben beim Loeschen liegen
Ampel / ampel (push) Failing after 21s

Neuer Bereich "Aufraeumen" auf der Modelle-Seite: Eintraege ohne heutige Rolle und Ordner, auf
die kein Eintrag zeigt, mit Groesse und Erklaerung (Rueckweg, Reserve, alte Drafts). Geloescht
wird nur auf Knopfdruck mit Rueckfrage. delete_model loescht keine Dateien mehr, die ein anderer
Eintrag nennt - seit den Bild-Zwillingen haette sonst das Loeschen eines Zwillings den Coder
mitgerissen. Der Projektor des Hirn-Zwillings liegt jetzt neben den Hirn-Gewichten, damit der
Original-Ordner loeschbar bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 13:17:11 +02:00
co-authored by Claude Opus 5.5
parent 133a491aca
commit 42363229d4
16 changed files with 340 additions and 17 deletions
+22
View File
@@ -8,6 +8,8 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm
GET /api/zeitplan Heute gelaufen / demnächst geplant
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
"""
@@ -21,7 +23,9 @@ from zoneinfo import ZoneInfo
import psutil
from config import MODELS_DIR
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import (
aufraeumen,
backup,
llamaswap,
maintenance,
@@ -220,3 +224,21 @@ def festgehalten_freigeben(baustein: str) -> dict:
if not update_verlauf.freigeben(baustein):
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
class LoeschAuftrag(BaseModel):
art: str # "eintrag" | "ordner"
name: str
@router.get("/modelle/aufraeumen")
def aufraeumen_liste() -> dict:
return {"kandidaten": aufraeumen.kandidaten()}
@router.post("/modelle/aufraeumen/loeschen")
def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name)
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
return ergebnis
+145
View File
@@ -0,0 +1,145 @@
"""
Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden?
Zwei Arten von Kandidaten:
• Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre
Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen.
• Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale,
Reserve-Modelle, Drafts aus früheren Versuchen).
Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
"""
import os
import shutil
from pathlib import Path
from config import MODELS_DIR
from services import llamaswap
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"}
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
HINWEISE = {
"Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn "
"einmal Probleme macht.",
"Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.",
"Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, "
"nie in Betrieb).",
"DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
"drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
"gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.",
"Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.",
"Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).",
}
def _ordner_von(pfad: str) -> str | None:
"""Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt)."""
if not os.path.isabs(pfad):
return None
try:
rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve())
except (ValueError, OSError):
return None
return rel.parts[0] if len(rel.parts) > 1 else None
def _genutzte_ordner(models: dict) -> dict[str, set[str]]:
"""Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft)."""
genutzt: dict[str, set[str]] = {}
for name, spec in models.items():
if not isinstance(spec, dict):
continue
for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt
if (ordner := _ordner_von(wort.strip("'\""))):
genutzt.setdefault(ordner, set()).add(name)
return genutzt
def _groesse(pfad: Path) -> int:
summe = 0
for wurzel, _, dateien in os.walk(pfad):
for datei in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, datei))
except OSError:
pass
return summe
def _immer_warm(cfg: dict) -> set[str]:
return {m for g in (cfg.get("groups") or {}).values()
if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []}
def kandidaten() -> list[dict]:
"""Alles, was sich löschen ließe — größte zuerst. Löscht nichts."""
cfg = llamaswap.read_config()
models = cfg.get("models") or {}
warm = _immer_warm(cfg)
genutzt = _genutzte_ordner(models)
liste: list[dict] = []
for name, spec in models.items():
aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []}
if aliase & AKTIVE_ROLLEN or name in warm:
continue
eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt
groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene)
rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle."
liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1),
"hinweis": HINWEISE.get(name, rolle)})
try:
ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir())
except OSError:
ordner = []
for pfad in ordner:
if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."):
continue
liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1),
"hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")})
return sorted(liste, key=lambda k: -k["groesse_gb"])
def _ordner_loeschen(name: str) -> int:
"""Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück."""
wurzel = Path(MODELS_DIR).resolve()
pfad = (wurzel / name).resolve()
if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."):
raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.")
groesse = _groesse(pfad)
shutil.rmtree(pfad)
return groesse
def loeschen(art: str, name: str) -> dict:
"""Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück."""
if not any(k["art"] == art and k["name"] == name for k in kandidaten()):
return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."}
if not AUF_DER_BOX:
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
frei = 0
try:
if art == "ordner":
frei = _ordner_loeschen(name)
else:
cfg = llamaswap.read_config()
models = cfg.get("models") or {}
vorher = _genutzte_ordner(models)
del models[name]
for gruppe in (cfg.get("groups") or {}).values():
if isinstance(gruppe, dict) and name in (gruppe.get("members") or []):
gruppe["members"] = [m for m in gruppe["members"] if m != name]
llamaswap.write_config(cfg)
nachher = _genutzte_ordner(models)
for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt
frei += _ordner_loeschen(ordner)
except (OSError, ValueError) as e:
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
+8 -2
View File
@@ -532,7 +532,13 @@ def delete_model(model_id: str) -> bool:
model_spec = models[model_id] or {}
cmd = str(model_spec.get("cmd", "")).strip()
if (m := _PATH_RE.search(cmd)):
# Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder.
# Dateien, die ein anderer Eintrag noch nennt, bleiben liegen — sonst risse das Löschen eines Zwillings
# den Coder mit. Dann wird nur der Eintrag entfernt.
andere = " ".join(str((s or {}).get("cmd", "")) for mid, s in models.items() if mid != model_id)
if (m := _PATH_RE.search(cmd)) and m.group(1).replace("'", "").replace('"', "") in andere:
m = None
if m:
path = m.group(1).replace("'", "").replace('"', "")
if path:
# 1. Haupt-GGUF-Datei löschen
@@ -561,7 +567,7 @@ def delete_model(model_id: str) -> bool:
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
if mmproj_match:
m_path = mmproj_match.group(1)
if os.path.exists(m_path):
if os.path.exists(m_path) and m_path not in andere:
try:
os.remove(m_path)
except Exception:
+67
View File
@@ -0,0 +1,67 @@
"""Aufräumen der Modell-Platte: nur Ungenutztes, nur auf Knopfdruck, geteilte Dateien bleiben."""
from pathlib import Path
import pytest
from services import aufraeumen, llamaswap
@pytest.fixture
def platte(tmp_path, monkeypatch):
"""Nachbau der Box: Hirn + Bild-Zwilling teilen Gewichte, der Projektor liegt im Original-Ordner."""
for ordner, datei, groesse in (("Hirn-GGUF", "hirn.gguf", 3000), ("Original-GGUF", "mmproj.gguf", 500),
("Coder-GGUF", "coder.gguf", 2000), ("Alt-GGUF", "alt.gguf", 4000),
("Muse-GGUF", "muse.gguf", 1500), ("Reserve-GGUF", "reserve.gguf", 2500),
("radar", "log.txt", 10), (".cache", "x", 10)):
(tmp_path / ordner).mkdir()
(tmp_path / ordner / datei).write_bytes(b"x" * groesse)
m = str(tmp_path)
cfg = {"models": {
"hirn": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf", "aliases": ["hermes", "fast"]},
"hirn-bild": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf --mmproj {m}/Original-GGUF/mmproj.gguf",
"aliases": ["vision"]},
"coder": {"cmd": f"llama-server -m {m}/Coder-GGUF/coder.gguf", "aliases": ["coder"]},
"alt": {"cmd": f"llama-server -m {m}/Alt-GGUF/alt.gguf"},
"muse": {"cmd": f"llama-server -m {m}/Muse-GGUF/muse.gguf", "aliases": ["debugger"]},
}, "groups": {"brains": {"persistent": True, "members": ["hirn"]}}}
monkeypatch.setattr(aufraeumen, "MODELS_DIR", tmp_path)
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", True)
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
return tmp_path, cfg
def test_kandidaten_sind_rollenlose_eintraege_und_verwaiste_ordner(platte):
namen = {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}
assert namen == {("eintrag", "alt"), ("eintrag", "muse"), ("ordner", "Reserve-GGUF")}
# Der Original-Ordner ist KEIN Kandidat: der Bild-Zwilling nutzt seinen Projektor.
muse = next(k for k in aufraeumen.kandidaten() if k["name"] == "muse")
assert "debugger" in muse["hinweis"]
def test_eintrag_loeschen_nimmt_ihn_aus_der_config_und_seinen_ordner_mit(platte):
wurzel, cfg = platte
ergebnis = aufraeumen.loeschen("eintrag", "alt")
assert ergebnis["ok"] and "alt gelöscht" in ergebnis["text"]
assert "alt" not in cfg["models"] and not (wurzel / "Alt-GGUF").exists()
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists() and (wurzel / "Coder-GGUF").exists()
def test_nur_aktuelle_kandidaten_und_nie_systemordner(platte):
wurzel, _ = platte
assert not aufraeumen.loeschen("ordner", "Hirn-GGUF")["ok"] # wird genutzt
assert not aufraeumen.loeschen("ordner", "radar")["ok"] # Systemordner
assert not aufraeumen.loeschen("ordner", "../etwas")["ok"]
assert (wurzel / "Hirn-GGUF").exists() and (wurzel / "radar").exists()
assert aufraeumen.loeschen("ordner", "Reserve-GGUF")["ok"] and not (wurzel / "Reserve-GGUF").exists()
def test_delete_model_laesst_geteilte_gewichte_liegen(platte):
"""Das Löschen des Bild-Zwillings darf die Gewichte des Hirns nicht mitnehmen (24.09.)."""
wurzel, cfg = platte
assert llamaswap.delete_model("hirn-bild") is True
assert "hirn-bild" not in cfg["models"]
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists()
# Im Zweifel liegen lassen: der Ordner taucht danach als verwaist im Aufräumen auf.
assert Path(wurzel / "Original-GGUF" / "mmproj.gguf").exists()
assert ("ordner", "Original-GGUF") in {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}