diff --git a/deploy/deploy.sh b/deploy/deploy.sh index d924350..77d6bbe 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -115,6 +115,12 @@ cp "$SRC/deploy/idle-radar-feed.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" # eigene Aenderungen; der Karten-Gutachter stempelt jede Karte mit einer Empfehlung. cp "$SRC/deploy/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" +# Trend-Radar (17.07.2026, Sa 05:15): Live-Puls aller Rollen + Engine-A/B + Watch-Liste + +# HF-Kandidaten-Suche → max. 1 Prüfstand-Kandidat/Woche. Prüfstand (So 01:00, --no-agent): +# volles Programm (Coding/Agent/Recherche/Deutsch/Vision) gegen die Amtsinhaber-Baseline; +# Harness + Suiten liegen im Repo ($SRC/deploy/pruefstand/), nur der Runner wird kopiert. +cp "$SRC/deploy/trend-radar-feed.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" +cp "$SRC/deploy/pruefstand/pruefstand.sh" "$HOME/.hermes/scripts/pruefstand.sh" # Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen # (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt). [ -d "$HOME/.hermes/profiles/werkstatt" ] && cp "$SRC/deploy/werkstatt-SOUL.md" "$HOME/.hermes/profiles/werkstatt/SOUL.md" @@ -122,7 +128,7 @@ cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" # statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb # --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing). [ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md" -chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" +chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" # Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes. mkdir -p "$HOME/.hermes/skills/wartung" cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md" diff --git a/deploy/pruefstand/harness.py b/deploy/pruefstand/harness.py new file mode 100644 index 0000000..604a826 --- /dev/null +++ b/deploy/pruefstand/harness.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +# Prüfstand-Harness (17.07.2026): fährt ECHTE Prüfungen gegen ein OpenAI-kompatibles +# Endpoint (llama-swap :8080 für Amtsinhaber, Bench-Port :5899 für Kandidaten) und +# bewertet MECHANISCH — bestanden/durchgefallen statt Bauchgefühl. +# +# Suiten (deploy/pruefstand/suites/): +# coding Aufgabe → Modell schreibt Code → echte Unit-Tests laufen im Sandkasten +# agent Mehrschritt-Aufgabe mit Werkzeugen (Dateien lesen/schreiben/löschen im +# Sandkasten, Tool-Calling-API) → bewertet am ENDZUSTAND + Tool-Fehlerquote +# recherche Fragen gegen mitgelieferte Dokumente → Pflicht-Stichworte + wörtliches +# Zitat, das mechanisch gegen die Dokumente geprüft wird (Ehrlichkeits-Gate) +# deutsch Lucy-Alltags-Turns → Richter-Modell (Zwei-Kritiker-Muster) vergibt Noten +# vision generiertes Testbild beschreiben (nur für Vision-Rollen) +# speed kurze + lange Probe, pp/tg aus den llama.cpp-Timings +# +# Nutzung: +# python3 harness.py --endpoint http://127.0.0.1:8080/v1 --model hermes \ +# --suites coding,agent --out ergebnis.json [--judge-model gpt-oss-120b] \ +# [--deadline-min 90] [--vision-image pfad.png] +# +# Lehren eingebaut: Reasoning-Modelle brauchen großzügiges max_tokens (Verdikt 14.07.: +# knappes Budget = leerer content, nie "kaputt"); Timings stehen im "timings"-Feld der +# llama.cpp-Antwort; jeder API-Fehler = Aufgabe durchgefallen mit Grund, nie Abbruch. +import argparse +import base64 +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import unicodedata +import urllib.error +import urllib.request + +HIER = os.path.dirname(os.path.abspath(__file__)) +SUITES_DIR = os.path.join(HIER, "suites") + + +# ---------------------------------------------------------------- HTTP-Helfer +def api_chat(endpoint, model, messages, max_tokens=1800, tools=None, timeout=420, + temperature=0.1): + """Ein Chat-Call. Gibt (message-dict, timings-dict, fehler-string) zurück.""" + body = {"model": model, "messages": messages, "max_tokens": max_tokens, + "temperature": temperature} + if tools: + body["tools"] = tools + body["tool_choice"] = "auto" + req = urllib.request.Request( + endpoint.rstrip("/") + "/chat/completions", + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + d = json.loads(r.read().decode("utf-8", "replace")) + msg = d["choices"][0]["message"] + return msg, d.get("timings") or {}, "" + except Exception as e: # Timeout, HTTP-Fehler, kaputtes JSON — alles ehrlich melden + return None, {}, f"{type(e).__name__}: {e}" + + +def inhalt_von(msg): + """content zuerst, reasoning_content als Notnagel (Reasoning-Modelle).""" + if not msg: + return "" + return (msg.get("content") or msg.get("reasoning_content") or "").strip() + + +def norm(s): + """Normalisierung fürs Zitat-Gate (identisch zur idle-radar-Mechanik).""" + s = unicodedata.normalize("NFKC", s or "") + s = "".join(ch for ch in s if ch.isalnum() or ch.isspace()) + return " ".join(s.lower().split()) + + +def code_block(text, sprache="python"): + """Letzten passenden Code-Block extrahieren; ohne Zaun: ganzen Text nehmen.""" + bloecke = re.findall(r"```(?:%s)?\s*\n(.*?)```" % re.escape(sprache), + text or "", re.DOTALL | re.IGNORECASE) + if bloecke: + return bloecke[-1] + bloecke = re.findall(r"```\s*\n(.*?)```", text or "", re.DOTALL) + return bloecke[-1] if bloecke else (text or "") + + +def lade_task(pfad): + with open(pfad, encoding="utf-8") as f: + return json.load(f) + + +# ---------------------------------------------------------------- Suite: coding +def suite_coding(cfg, ergebnisse): + basis = os.path.join(SUITES_DIR, "coding", "aufgaben") + for tid in sorted(os.listdir(basis)): + tdir = os.path.join(basis, tid) + if not os.path.isfile(os.path.join(tdir, "task.json")): + continue + if cfg.abgelaufen(): + ergebnisse.append({"suite": "coding", "id": tid, "pass": False, + "grund": "Zeitbudget erschöpft — übersprungen"}) + continue + task = lade_task(os.path.join(tdir, "task.json")) + t0 = time.time() + prompt = task["prompt"] + for name, inhalt in (task.get("dateien") or {}).items(): + prompt += f"\n\n--- Datei `{name}` (Ist-Stand) ---\n```python\n{inhalt}\n```" + prompt += ("\n\nAntworte mit GENAU EINEM ```python-Code-Block, der den " + f"vollständigen Inhalt der Datei `{task['antwort_datei']}` enthält. " + "Keine Erklärungen außerhalb des Blocks.") + msg, tim, err = api_chat(cfg.endpoint, cfg.model, + [{"role": "user", "content": prompt}], + max_tokens=task.get("max_tokens", 2600)) + eintrag = {"suite": "coding", "id": tid, "dauer_s": round(time.time() - t0, 1), + "tg_tps": tim.get("predicted_per_second"), + "pp_tps": tim.get("prompt_per_second")} + if err: + eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"}) + ergebnisse.append(eintrag) + continue + code = code_block(inhalt_von(msg)) + sbx = tempfile.mkdtemp(prefix="pruefstand-coding-") + try: + for name, inhalt in (task.get("dateien") or {}).items(): + with open(os.path.join(sbx, name), "w", encoding="utf-8") as f: + f.write(inhalt) + with open(os.path.join(sbx, task["antwort_datei"]), "w", + encoding="utf-8") as f: + f.write(code) + shutil.copy(os.path.join(tdir, "verify.py"), os.path.join(sbx, "verify.py")) + p = subprocess.run([sys.executable, "verify.py"], cwd=sbx, + capture_output=True, text=True, + timeout=task.get("timeout_s", 120)) + eintrag["pass"] = (p.returncode == 0) + if p.returncode != 0: + eintrag["grund"] = (p.stdout + p.stderr).strip()[-400:] + except subprocess.TimeoutExpired: + eintrag.update({"pass": False, "grund": "verify-Timeout (Endlosschleife?)"}) + except Exception as e: + eintrag.update({"pass": False, "grund": f"Sandkasten-Fehler: {e}"}) + finally: + shutil.rmtree(sbx, ignore_errors=True) + ergebnisse.append(eintrag) + + +# ---------------------------------------------------------------- Suite: agent +WERKZEUGE = [ + {"type": "function", "function": { + "name": "liste_dateien", + "description": "Listet alle Dateien im Arbeitsverzeichnis (rekursiv).", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "lies_datei", + "description": "Liest eine Datei aus dem Arbeitsverzeichnis.", + "parameters": {"type": "object", "properties": { + "pfad": {"type": "string"}}, "required": ["pfad"]}}}, + {"type": "function", "function": { + "name": "schreibe_datei", + "description": "Schreibt/überschreibt eine Datei im Arbeitsverzeichnis.", + "parameters": {"type": "object", "properties": { + "pfad": {"type": "string"}, "inhalt": {"type": "string"}}, + "required": ["pfad", "inhalt"]}}}, + {"type": "function", "function": { + "name": "loesche_datei", + "description": "Löscht eine Datei im Arbeitsverzeichnis.", + "parameters": {"type": "object", "properties": { + "pfad": {"type": "string"}}, "required": ["pfad"]}}}, + {"type": "function", "function": { + "name": "fertig", + "description": "Aufgabe abgeschlossen. Kurze Abschluss-Meldung übergeben.", + "parameters": {"type": "object", "properties": { + "meldung": {"type": "string"}}, "required": ["meldung"]}}}, +] + + +def sicherer_pfad(sbx, pfad): + """Pfad-Ausbruch verhindern — Sandkasten bleibt Sandkasten.""" + ziel = os.path.realpath(os.path.join(sbx, pfad or "")) + if not ziel.startswith(os.path.realpath(sbx) + os.sep): + raise ValueError(f"Pfad verlässt den Sandkasten: {pfad}") + return ziel + + +def werkzeug_ausfuehren(sbx, name, args): + if name == "liste_dateien": + alle = [] + for wurzel, _, dateien in os.walk(sbx): + for d in dateien: + alle.append(os.path.relpath(os.path.join(wurzel, d), sbx)) + return "\n".join(sorted(alle)) or "(leer)" + if name == "lies_datei": + with open(sicherer_pfad(sbx, args["pfad"]), encoding="utf-8", + errors="replace") as f: + return f.read()[:20000] + if name == "schreibe_datei": + ziel = sicherer_pfad(sbx, args["pfad"]) + os.makedirs(os.path.dirname(ziel), exist_ok=True) + with open(ziel, "w", encoding="utf-8") as f: + f.write(args.get("inhalt", "")) + return f"geschrieben: {args['pfad']}" + if name == "loesche_datei": + os.remove(sicherer_pfad(sbx, args["pfad"])) + return f"gelöscht: {args['pfad']}" + raise ValueError(f"unbekanntes Werkzeug: {name}") + + +def suite_agent(cfg, ergebnisse): + basis = os.path.join(SUITES_DIR, "agent", "aufgaben") + for tid in sorted(os.listdir(basis)): + tdir = os.path.join(basis, tid) + if not os.path.isfile(os.path.join(tdir, "task.json")): + continue + if cfg.abgelaufen(): + ergebnisse.append({"suite": "agent", "id": tid, "pass": False, + "grund": "Zeitbudget erschöpft — übersprungen"}) + continue + task = lade_task(os.path.join(tdir, "task.json")) + t0 = time.time() + sbx = tempfile.mkdtemp(prefix="pruefstand-agent-") + tool_fehler = 0 + schritte = 0 + try: + for name, inhalt in (task.get("sandkasten") or {}).items(): + ziel = os.path.join(sbx, name) + os.makedirs(os.path.dirname(ziel), exist_ok=True) + with open(ziel, "w", encoding="utf-8") as f: + f.write(inhalt) + messages = [ + {"role": "system", "content": + "Du bist ein Arbeits-Agent mit Datei-Werkzeugen in einem " + "Sandkasten-Verzeichnis. Erledige die Aufgabe Schritt für Schritt " + "mit den Werkzeugen und rufe am Ende `fertig` auf."}, + {"role": "user", "content": task["prompt"]}, + ] + fertig_gemeldet = False + for _ in range(task.get("max_schritte", 8)): + schritte += 1 + msg, _tim, err = api_chat(cfg.endpoint, cfg.model, messages, + max_tokens=1800, tools=WERKZEUGE) + if err: + break + calls = msg.get("tool_calls") or [] + if not calls: + break # Agent redet statt zu handeln — Endzustand zählt trotzdem + messages.append({"role": "assistant", + "content": msg.get("content") or "", + "tool_calls": calls}) + for call in calls: + fn = (call.get("function") or {}).get("name") or "?" + try: + args = json.loads((call.get("function") or {}) + .get("arguments") or "{}") + except Exception: + args, fn_ok = {}, False + tool_fehler += 1 + antwort = "FEHLER: Argumente waren kein gültiges JSON." + else: + fn_ok = True + if fn_ok: + if fn == "fertig": + fertig_gemeldet = True + antwort = "ok" + else: + try: + antwort = werkzeug_ausfuehren(sbx, fn, args) + except Exception as e: + tool_fehler += 1 + antwort = f"FEHLER: {e}" + messages.append({"role": "tool", + "tool_call_id": call.get("id") or "0", + "content": antwort}) + if fertig_gemeldet: + break + # -------- Endzustand prüfen (die einzige Wahrheit) + fehlschlaege = [] + for check in task.get("checks", []): + if "nicht_vorhanden" in check: + if os.path.exists(os.path.join(sbx, check["nicht_vorhanden"])): + fehlschlaege.append( + f"{check['nicht_vorhanden']} existiert noch") + continue + pfad = os.path.join(sbx, check["datei"]) + if not os.path.isfile(pfad): + fehlschlaege.append(f"{check['datei']} fehlt") + continue + with open(pfad, encoding="utf-8", errors="replace") as f: + text = f.read() + if check.get("regex") and not re.search(check["regex"], text, + re.IGNORECASE | re.MULTILINE): + fehlschlaege.append( + f"{check['datei']}: Muster fehlt ({check['regex']})") + ergebnisse.append({ + "suite": "agent", "id": tid, "pass": not fehlschlaege, + "grund": "; ".join(fehlschlaege)[:400] if fehlschlaege else "", + "schritte": schritte, "tool_fehler": tool_fehler, + "fertig_gemeldet": fertig_gemeldet, + "dauer_s": round(time.time() - t0, 1)}) + except Exception as e: + ergebnisse.append({"suite": "agent", "id": tid, "pass": False, + "grund": f"Harness-Fehler: {e}", + "dauer_s": round(time.time() - t0, 1)}) + finally: + shutil.rmtree(sbx, ignore_errors=True) + + +# ---------------------------------------------------------------- Suite: recherche +def suite_recherche(cfg, ergebnisse): + basis = os.path.join(SUITES_DIR, "recherche") + dok_dir = os.path.join(basis, "dokumente") + dokumente = {} + for d in sorted(os.listdir(dok_dir)): + with open(os.path.join(dok_dir, d), encoding="utf-8") as f: + dokumente[d] = f.read() + alle_norm = norm("\n".join(dokumente.values())) + material = "\n\n".join(f"===== DOKUMENT {n} =====\n{t}" + for n, t in dokumente.items()) + for tid in sorted(os.listdir(os.path.join(basis, "aufgaben"))): + tpfad = os.path.join(basis, "aufgaben", tid) + if not tpfad.endswith(".json"): + continue + if cfg.abgelaufen(): + ergebnisse.append({"suite": "recherche", "id": tid, "pass": False, + "grund": "Zeitbudget erschöpft — übersprungen"}) + continue + task = lade_task(tpfad) + t0 = time.time() + prompt = (f"{material}\n\nFRAGE: {task['frage']}\n\n" + "Beantworte die Frage NUR aus den Dokumenten oben, auf Deutsch, " + "in höchstens 4 Sätzen. Letzte Zeile deiner Antwort: " + 'ZITAT: ""') + msg, tim, err = api_chat(cfg.endpoint, cfg.model, + [{"role": "user", "content": prompt}], + max_tokens=1200) + eintrag = {"suite": "recherche", "id": task.get("id", tid), + "dauer_s": round(time.time() - t0, 1), + "tg_tps": tim.get("predicted_per_second"), + "pp_tps": tim.get("prompt_per_second")} + if err: + eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"}) + ergebnisse.append(eintrag) + continue + antwort = inhalt_von(msg) + fehlschlaege = [] + for gruppe in task.get("muss_gruppen", []): + if not any(k.lower() in antwort.lower() for k in gruppe): + fehlschlaege.append(f"Pflicht-Stichwort fehlt: {'/'.join(gruppe)}") + m = re.search(r'ZITAT:\s*[„"]?(.+?)["“”]?\s*$', antwort, + re.MULTILINE | re.DOTALL) + zitat = (m.group(1).strip() if m else "") + if task.get("zitat_pflicht", True): + if not zitat: + fehlschlaege.append("kein ZITAT geliefert") + elif norm(zitat) not in alle_norm: + fehlschlaege.append("ZITAT nicht wörtlich in den Dokumenten " + "(Ehrlichkeits-Gate)") + eintrag["pass"] = not fehlschlaege + if fehlschlaege: + eintrag["grund"] = "; ".join(fehlschlaege)[:400] + ergebnisse.append(eintrag) + + +# ---------------------------------------------------------------- Suite: deutsch +RICHTER_RASTER = ( + "Du bist ein strenger Deutsch-Richter für eine Sprach-Assistentin (Lucy): " + "locker, natürlich, Du-Form, kurze gesprochene Sätze, kein Beamten- oder " + "Übersetzungsdeutsch, keine Anglizismen-Ketten, fachlich korrekt. Bewerte die " + "ANTWORT unten auf einer Skala 1–10 (10 = klingt wie eine deutsche " + "Muttersprachlerin im Alltag). Antworte EXAKT in diesem Format:\n" + "NOTE: \nBEGRUENDUNG: ") + + +def suite_deutsch(cfg, ergebnisse): + aufgaben = lade_task(os.path.join(SUITES_DIR, "deutsch", "aufgaben.json")) + for task in aufgaben: + if cfg.abgelaufen(): + ergebnisse.append({"suite": "deutsch", "id": task["id"], "pass": False, + "grund": "Zeitbudget erschöpft — übersprungen"}) + continue + t0 = time.time() + msg, tim, err = api_chat(cfg.endpoint, cfg.model, + [{"role": "user", "content": task["prompt"]}], + max_tokens=900) + eintrag = {"suite": "deutsch", "id": task["id"], + "dauer_s": round(time.time() - t0, 1), + "tg_tps": tim.get("predicted_per_second"), + "pp_tps": tim.get("prompt_per_second")} + if err: + eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"}) + ergebnisse.append(eintrag) + continue + antwort = inhalt_von(msg) + jmsg, _jt, jerr = api_chat(cfg.judge_endpoint, cfg.judge_model, [ + {"role": "system", "content": RICHTER_RASTER}, + {"role": "user", "content": + f"AUFGABE AN DIE ASSISTENTIN: {task['prompt']}\n\n" + f"ANTWORT:\n{antwort}"}], + max_tokens=2200, temperature=0.2) + note = None + if not jerr: + m = re.search(r"NOTE:\s*(\d{1,2})", inhalt_von(jmsg)) + if m: + note = max(1, min(10, int(m.group(1)))) + eintrag["note"] = note + eintrag["pass"] = (note is not None and note >= 6) + if note is None: + eintrag["grund"] = f"Richter ohne Note ({jerr or 'Format'})" + ergebnisse.append(eintrag) + + +# ---------------------------------------------------------------- Suite: vision +def suite_vision(cfg, ergebnisse): + task = lade_task(os.path.join(SUITES_DIR, "vision", "task.json")) + bild = cfg.vision_image or os.path.join(SUITES_DIR, "vision", "testbild.png") + if not os.path.isfile(bild): + # Testbild deterministisch erzeugen (reines Python, kein PIL nötig) + subprocess.run([sys.executable, + os.path.join(SUITES_DIR, "vision", "gen_testbild.py"), bild], + check=False, timeout=30) + if not os.path.isfile(bild): + ergebnisse.append({"suite": "vision", "id": "testbild", "pass": False, + "grund": "Testbild fehlt und konnte nicht erzeugt werden"}) + return + with open(bild, "rb") as f: + b64 = base64.b64encode(f.read()).decode("ascii") + t0 = time.time() + msg, tim, err = api_chat(cfg.endpoint, cfg.model, [ + {"role": "user", "content": [ + {"type": "text", "text": task["prompt"]}, + {"type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}}]}], + max_tokens=900, timeout=600) + eintrag = {"suite": "vision", "id": "testbild", + "dauer_s": round(time.time() - t0, 1), + "tg_tps": tim.get("predicted_per_second")} + if err: + eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"}) + ergebnisse.append(eintrag) + return + antwort = inhalt_von(msg) + fehlschlaege = [f"nicht erkannt: {'/'.join(g)}" + for g in task.get("muss_gruppen", []) + if not any(k.lower() in antwort.lower() for k in g)] + eintrag["pass"] = not fehlschlaege + if fehlschlaege: + eintrag["grund"] = "; ".join(fehlschlaege)[:300] + eintrag["antwort_auszug"] = antwort[:200] + ergebnisse.append(eintrag) + + +# ---------------------------------------------------------------- Suite: speed +LANG_BAUSTEIN = ("Die Box protokolliert jeden Dienststart mit Zeitstempel, " + "Dienstname und Ergebnis, damit die Morgenlage Abweichungen " + "gegen die Vorwoche erkennen und einordnen kann. ") + + +def suite_speed(cfg, ergebnisse): + # Kurz-Probe: misst tg (Alltags-Turn) — 2 Läufe, erster wärmt den Cache an. + for lauf in ("warm", "kurz"): + t0 = time.time() + msg, tim, err = api_chat(cfg.endpoint, cfg.model, [ + {"role": "user", "content": + "Erkläre in drei kurzen Sätzen, was ein Mixture-of-Experts-Modell " + "ist."}], max_tokens=200, temperature=0) + if lauf == "warm": + continue + ergebnisse.append({"suite": "speed", "id": "kurz", "pass": not err, + "grund": err[:200] if err else "", + "dauer_s": round(time.time() - t0, 1), + "tg_tps": tim.get("predicted_per_second"), + "pp_tps": tim.get("prompt_per_second")}) + # Lang-Probe: ~12k-Token-Prompt → misst pp bei echtem Kontext (ROCm-Watch-Metrik). + if cfg.abgelaufen(): + ergebnisse.append({"suite": "speed", "id": "lang", "pass": False, + "grund": "Zeitbudget erschöpft — übersprungen"}) + return + lang = LANG_BAUSTEIN * 550 + t0 = time.time() + msg, tim, err = api_chat(cfg.endpoint, cfg.model, [ + {"role": "user", "content": + lang + "\n\nWie oft steht sinngemäß derselbe Satz oben? Antworte in " + "einem Satz."}], max_tokens=80, temperature=0, timeout=900) + ergebnisse.append({"suite": "speed", "id": "lang", "pass": not err, + "grund": err[:200] if err else "", + "dauer_s": round(time.time() - t0, 1), + "tg_tps": tim.get("predicted_per_second"), + "pp_tps": tim.get("prompt_per_second"), + "prompt_tokens": tim.get("prompt_n")}) + + +# ---------------------------------------------------------------- Hauptlauf +SUITE_FUNKS = {"coding": suite_coding, "agent": suite_agent, + "recherche": suite_recherche, "deutsch": suite_deutsch, + "vision": suite_vision, "speed": suite_speed} + + +class Konfig: + def __init__(self, args): + self.endpoint = args.endpoint + self.model = args.model + self.judge_endpoint = args.judge_endpoint or args.endpoint + self.judge_model = args.judge_model + self.vision_image = args.vision_image + self.deadline = (time.time() + args.deadline_min * 60 + if args.deadline_min else None) + + def abgelaufen(self): + return self.deadline is not None and time.time() > self.deadline + + +def main(): + ap = argparse.ArgumentParser(description="Prüfstand-Harness") + ap.add_argument("--endpoint", required=True) + ap.add_argument("--model", required=True) + ap.add_argument("--suites", required=True, + help="kommagetrennt: coding,agent,recherche,deutsch,vision,speed") + ap.add_argument("--out", required=True) + ap.add_argument("--judge-endpoint", default="") + ap.add_argument("--judge-model", default="gpt-oss-120b") + ap.add_argument("--vision-image", default="") + ap.add_argument("--deadline-min", type=float, default=0) + args = ap.parse_args() + + cfg = Konfig(args) + ergebnisse = [] + for name in [s.strip() for s in args.suites.split(",") if s.strip()]: + if name not in SUITE_FUNKS: + ergebnisse.append({"suite": name, "id": "-", "pass": False, + "grund": "unbekannte Suite"}) + continue + try: + SUITE_FUNKS[name](cfg, ergebnisse) + except Exception as e: # kaputte Suite darf den Gesamtlauf nicht töten + ergebnisse.append({"suite": name, "id": "-", "pass": False, + "grund": f"Suite-Absturz: {type(e).__name__}: {e}"}) + + # -------- Zusammenfassung je Suite (bestanden/gesamt + Tempo-Mittel) + zsm = {} + for e in ergebnisse: + s = zsm.setdefault(e["suite"], {"bestanden": 0, "gesamt": 0, + "tg": [], "pp": [], "noten": []}) + s["gesamt"] += 1 + s["bestanden"] += 1 if e.get("pass") else 0 + if e.get("tg_tps"): + s["tg"].append(e["tg_tps"]) + if e.get("pp_tps"): + s["pp"].append(e["pp_tps"]) + if e.get("note") is not None: + s["noten"].append(e["note"]) + for s in zsm.values(): + s["tg_mittel"] = round(sum(s["tg"]) / len(s["tg"]), 1) if s["tg"] else None + s["pp_mittel"] = round(sum(s["pp"]) / len(s["pp"]), 1) if s["pp"] else None + s["note_mittel"] = (round(sum(s["noten"]) / len(s["noten"]), 1) + if s["noten"] else None) + del s["tg"], s["pp"], s["noten"] + + with open(args.out, "w", encoding="utf-8") as f: + json.dump({"modell": args.model, "endpoint": args.endpoint, + "erhoben": time.strftime("%Y-%m-%d %H:%M"), + "zusammenfassung": zsm, "einzeln": ergebnisse}, + f, ensure_ascii=False, indent=1) + # Kurzfassung auf stdout (für Runner-Logs) + for name, s in zsm.items(): + extra = f" · Note {s['note_mittel']}" if s.get("note_mittel") else "" + tempo = (f" · tg {s['tg_mittel']} t/s" if s.get("tg_mittel") else "") + print(f"{name}: {s['bestanden']}/{s['gesamt']}{extra}{tempo}") + + +if __name__ == "__main__": + main() diff --git a/deploy/pruefstand/pruefstand.sh b/deploy/pruefstand/pruefstand.sh new file mode 100644 index 0000000..d6bfb35 --- /dev/null +++ b/deploy/pruefstand/pruefstand.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# Prüfstand (17.07.2026): das "volle Programm" für Modell-Kandidaten — echte Coding-, +# Agenten-, Recherche-, Deutsch- und Vision-Prüfungen (harness.py) plus Tempo, verglichen +# gegen die BASELINE der Amtsinhaber. Läuft als Cron (So 01:00, --no-agent: stdout wird +# wörtlich zugestellt; LEERER stdout = stille Nacht). +# +# Modi: +# (ohne Argument) Cron-Modus: Baseline fehlt → Baseline messen; sonst ältesten +# Kandidaten aus der Queue prüfen; nichts zu tun → still. +# --baseline Amtsinhaber über llama-swap (:8080) neu vermessen. +# --kandidat einen Kandidaten-Spec (JSON) direkt prüfen. +# +# Leitplanken (User-Entscheid 17.07.): max 1 Kandidat je Lauf · Download-Deckel 35 GB +# (größere Kandidaten → Karte, Download erst nach Klick) · eigener Cache mit +# Auto-Aufräumen · Live-Betrieb bleibt unangetastet (eigener Server auf :5899) · +# der Prüfstand EMPFIEHLT NUR — Rollen-Wechsel entscheidet der Commander per Karte. +# RAM-Wache vor dem Serverstart; Heredoc+Pipe-Falle vermieden (Temp-Dateien, Lehre 10.07.). +set -uo pipefail + +MC="$HOME/mission-control-v2" +PSD="$MC/deploy/pruefstand" +VAULT="$HOME/wissens-vault" +STATE="$HOME/.hermes/state/pruefstand" +CACHE="/srv/models/pruefstand-cache" +HERMES="$HOME/.local/bin/hermes" +BENCH_BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}" +BENCH_PORT="${BENCH_PORT:-5899}" +LIVE="http://127.0.0.1:8080/v1" +JUDGE_MODEL="${PRUEFSTAND_JUDGE:-gpt-oss-120b}" +DECKEL_GB="${PRUEFSTAND_DECKEL_GB:-35}" +DEADLINE_MIN="${PRUEFSTAND_DEADLINE_MIN:-100}" + +mkdir -p "$STATE/queue" "$STATE/done" "$STATE/ergebnisse" + +# Suiten je Rolle (Amtsinhaber-Alias = Rollenname in llama-swap) +suiten_fuer() { + case "$1" in + hermes) echo "speed,agent,recherche,deutsch" ;; + coder) echo "speed,coding,agent" ;; + heavy) echo "speed,coding,recherche,deutsch" ;; + vision) echo "speed,vision" ;; + scout) echo "speed,agent,vision" ;; + *) echo "speed,recherche,deutsch" ;; + esac +} + +briefkasten() { # $1=Betreff $2=Text (stiller Chronik-Spiegel, source=pruefstand) + curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \ + -H 'Content-Type: application/json' \ + --data "$(python3 - "$1" "$2" <<'PY' +import json, sys +print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1], + "source": "pruefstand", "priority": "silent"})) +PY +)" >/dev/null 2>&1 || true +} + +zusammenfassung_lesen() { # $1=ergebnis.json → kompakte Zeilen "suite: x/y · Note · tg" + python3 - "$1" <<'PY' +import json, sys +try: + z = json.load(open(sys.argv[1])).get("zusammenfassung", {}) +except Exception: + print("(Ergebnis nicht lesbar)"); raise SystemExit +for name, s in z.items(): + teile = [f"{s['bestanden']}/{s['gesamt']}"] + if s.get("note_mittel"): teile.append(f"Note {s['note_mittel']}") + if s.get("tg_mittel"): teile.append(f"tg {s['tg_mittel']} t/s") + if s.get("pp_mittel"): teile.append(f"pp {s['pp_mittel']} t/s") + print(f" {name}: " + " · ".join(teile)) +PY +} + +# ---------------------------------------------------------------- Baseline +baseline_lauf() { + echo "## PRÜFSTAND: Basislinie der Amtsinhaber (erhoben am $(date '+%d.%m.%Y %H:%M'))" + echo "(Echte Prüfungen: Coding mit Unit-Tests, Agenten-Aufgaben, Recherche mit Zitat-Gate, Deutsch-Richter, Tempo.)" + local rollen="hermes coder heavy vision scout" + for rolle in $rollen; do + local out="$STATE/ergebnisse/baseline-${rolle}.json" + echo + echo "### Amtsinhaber \`$rolle\`" + python3 "$PSD/harness.py" --endpoint "$LIVE" --model "$rolle" \ + --suites "$(suiten_fuer "$rolle")" --out "$out" \ + --judge-endpoint "$LIVE" --judge-model "$JUDGE_MODEL" \ + --deadline-min 30 2>&1 | tail -8 + done + # embed: nur Funktions- und Latenz-Probe (Suiten ergeben hier keinen Sinn) + echo + echo "### Amtsinhaber \`embed\`" + python3 - "$LIVE" <<'PY' +import json, sys, time, urllib.request +t0 = time.time() +req = urllib.request.Request(sys.argv[1].rstrip("/") + "/embeddings", + data=json.dumps({"model": "embed", "input": "Prüfstand-Probe"}).encode(), + headers={"Content-Type": "application/json"}) +try: + with urllib.request.urlopen(req, timeout=60) as r: + d = json.load(r) + dim = len(d["data"][0]["embedding"]) + print(f" ok · Dimension {dim} · {round((time.time()-t0)*1000)} ms") +except Exception as e: + print(f" FEHLER: {e}") +PY + # Sammel-Baseline schreiben (eine Datei, die Kandidaten-Läufe vergleichen) + python3 - "$STATE" <<'PY' +import glob, json, os, sys, time +state = sys.argv[1] +basis = {} +for f in glob.glob(os.path.join(state, "ergebnisse", "baseline-*.json")): + rolle = os.path.basename(f)[len("baseline-"):-len(".json")] + try: + basis[rolle] = json.load(open(f)).get("zusammenfassung", {}) + except Exception: + pass +json.dump({"erhoben": time.strftime("%Y-%m-%d %H:%M"), "rollen": basis}, + open(os.path.join(state, "baseline.json"), "w"), ensure_ascii=False, indent=1) +print(f"\nBasislinie gespeichert ({len(basis)} Rollen).") +PY + briefkasten "Prüfstand" "Basislinie der Amtsinhaber erhoben — künftige Kandidaten werden dagegen verglichen." +} + +# ---------------------------------------------------------------- Kandidat +kandidat_lauf() { # $1=spec.json + local SPEC="$1" + local KEY ROLLE HF_ID WARUM + KEY="$(jq -r '.key' "$SPEC")" + ROLLE="$(jq -r '.rolle' "$SPEC")" + HF_ID="$(jq -r '.hf_id' "$SPEC")" + WARUM="$(jq -r '.warum // ""' "$SPEC")" + echo "## PRÜFSTAND: Kandidat \`$HF_ID\` für Rolle \`$ROLLE\` (am $(date '+%d.%m.%Y %H:%M'))" + [ -n "$WARUM" ] && echo "Anlass: $WARUM" + + if [ -f "$STATE/done/${KEY}.json" ]; then + echo "Schon geprüft (State done/${KEY}) — nichts zu tun." + return 0 + fi + + # ---- GGUF im HF-Repo finden (bevorzugt ~Q4, ein Stück, Deckel prüfen) + local TREE_JSON="$STATE/tmp-tree-${KEY}.json" + curl -sf --max-time 60 \ + "https://huggingface.co/api/models/${HF_ID}/tree/main?recursive=true" \ + -o "$TREE_JSON" || { echo "HF-API nicht erreichbar — Abbruch, Spec bleibt in der Queue."; return 1; } + local AUSWAHL + AUSWAHL="$(python3 - "$TREE_JSON" "$DECKEL_GB" <<'PY' +import json, re, sys +baum = json.load(open(sys.argv[1])) +deckel = float(sys.argv[2]) * 2**30 +dateien = [(e.get("path",""), (e.get("lfs") or {}).get("size") or e.get("size") or 0) + for e in baum if isinstance(e, dict)] +ggufs = [(p, g) for p, g in dateien if p.lower().endswith(".gguf")] +mmproj = next((p for p, g in ggufs if "mmproj" in p.lower()), "") +haupt = [(p, g) for p, g in ggufs if "mmproj" not in p.lower()] +# Split-GGUFs (…-00001-of-…) klammern wir in v1 aus — sauberer Einzeldatei-Download +haupt = [(p, g) for p, g in haupt if not re.search(r"-\d{5}-of-\d{5}", p)] +prio = ["q4_k_m", "ud-q4_k_m", "q4_k_s", "iq4", "q5_k_m", "q4", "q8_0"] +def rang(p): + pl = p.lower() + for i, q in enumerate(prio): + if q in pl: + return i + return len(prio) +haupt.sort(key=lambda t: (rang(t[0]), t[1])) +if not haupt: + print("KEINE"); raise SystemExit +pfad, groesse = haupt[0] +status = "OK" if groesse and groesse <= deckel else "ZU_GROSS" +print(status); print(pfad); print(groesse or 0); print(mmproj) +PY +)" + rm -f "$TREE_JSON" + local STATUS PFAD GROESSE MMPROJ + STATUS="$(printf '%s\n' "$AUSWAHL" | sed -n 1p)" + PFAD="$(printf '%s\n' "$AUSWAHL" | sed -n 2p)" + GROESSE="$(printf '%s\n' "$AUSWAHL" | sed -n 3p)" + MMPROJ="$(printf '%s\n' "$AUSWAHL" | sed -n 4p)" + local GB=$(( ${GROESSE:-0} / 1073741824 )) + + if [ "$STATUS" = "KEINE" ]; then + echo "Kein brauchbares Einzel-GGUF im Repo (nur Splits oder gar keins) — Karte statt Download." + "$HERMES" kanban create "Prüfstand: ${HF_ID} braucht Handarbeit (kein Einzel-GGUF)" \ + --body "Kandidat für Rolle ${ROLLE}. Im HF-Repo liegt kein Einzeldatei-GGUF ≤ ${DECKEL_GB} GB (vermutlich Split-Dateien). Bei Interesse manuell laden und 'bash ~/mission-control-v2/deploy/pruefstand/pruefstand.sh --kandidat ' fahren. Anlass: ${WARUM}" \ + --triage --created-by pruefstand --idempotency-key "pruefstand-${KEY}" >/dev/null 2>&1 || true + mv "$SPEC" "$STATE/done/${KEY}.spec.json" + echo "{\"status\":\"kein_gguf\"}" > "$STATE/done/${KEY}.json" + return 0 + fi + if [ "$STATUS" = "ZU_GROSS" ]; then + echo "GGUF ${PFAD} ist ${GB} GB > Deckel ${DECKEL_GB} GB — Download wartet auf deinen Klick (Karte liegt bereit)." + "$HERMES" kanban create "Prüfstand: ${HF_ID} (${GB} GB) — Download freigeben?" \ + --body "Kandidat für Rolle ${ROLLE}, Datei ${PFAD} (${GB} GB) liegt ÜBER dem Autonomie-Deckel von ${DECKEL_GB} GB (Entscheid 17.07.). Nach Freigabe: Spec wieder in ~/.hermes/state/pruefstand/queue/ legen und PRUEFSTAND_DECKEL_GB erhöhen oder manuell laden. Anlass: ${WARUM}" \ + --triage --created-by pruefstand --idempotency-key "pruefstand-${KEY}" >/dev/null 2>&1 || true + mv "$SPEC" "$STATE/done/${KEY}.spec.json" + echo "{\"status\":\"zu_gross\",\"gb\":${GB}}" > "$STATE/done/${KEY}.json" + briefkasten "Prüfstand" "Kandidat ${HF_ID} (${GB} GB) wartet auf Download-Freigabe (Deckel ${DECKEL_GB} GB)." + return 0 + fi + + # ---- Platz- und RAM-Wache + local FREI_GB + FREI_GB="$(df --output=avail -BG /srv/models | tail -1 | tr -dc '0-9')" + if [ "${FREI_GB:-0}" -lt $(( GB + 20 )) ]; then + echo "Zu wenig Platz auf /srv/models (${FREI_GB} GB frei, gebraucht ~$((GB+20))) — Lauf verschoben, Spec bleibt in der Queue." + return 1 + fi + + # ---- Download in den Cache (mit Resume, Auto-Aufräumen am Ende) + mkdir -p "$CACHE/$KEY" + local ZIEL="$CACHE/$KEY/$(basename "$PFAD")" + echo "Lade ${PFAD} (${GB} GB) …" + curl -fL --retry 3 -C - --max-time 7200 -o "$ZIEL" \ + "https://huggingface.co/${HF_ID}/resolve/main/${PFAD}" \ + || { echo "Download fehlgeschlagen — Spec bleibt in der Queue."; return 1; } + local MM_ZIEL="" + if [ -n "$MMPROJ" ] && { [ "$ROLLE" = "vision" ] || [ "$ROLLE" = "scout" ]; }; then + MM_ZIEL="$CACHE/$KEY/$(basename "$MMPROJ")" + curl -fL --retry 3 -C - --max-time 3600 -o "$MM_ZIEL" \ + "https://huggingface.co/${HF_ID}/resolve/main/${MMPROJ}" || MM_ZIEL="" + fi + + local MEM_FREI_GB + MEM_FREI_GB="$(awk '/MemAvailable/{printf "%d", $2/1048576}' /proc/meminfo)" + if [ "${MEM_FREI_GB:-0}" -lt $(( GB + 8 )) ]; then + echo "Zu wenig freier RAM (${MEM_FREI_GB} GB) für ein ${GB}-GB-Modell neben dem Warm-Set — Lauf verschoben." + return 1 + fi + + # ---- Kandidaten-Server auf dem Bench-Port (Live-Betrieb unangetastet) + echo "Starte Kandidaten-Server (:${BENCH_PORT}) …" + local MMFLAG="" + [ -n "$MM_ZIEL" ] && MMFLAG="--mmproj $MM_ZIEL" + $BENCH_BIN -m "$ZIEL" --host 127.0.0.1 --port "$BENCH_PORT" \ + -c 32768 -ngl 999 -fa on --no-mmap --jinja $MMFLAG \ + >/tmp/pruefstand-server.log 2>&1 & + local SPID=$! + trap 'kill $SPID 2>/dev/null; wait $SPID 2>/dev/null' RETURN + local BEREIT=0 + for i in $(seq 1 240); do + curl -s --max-time 2 "http://127.0.0.1:$BENCH_PORT/health" | grep -q ok && { BEREIT=1; break; } + sleep 2 + kill -0 $SPID 2>/dev/null || break + done + if [ "$BEREIT" != "1" ]; then + echo "LADE-CRASH — Kandidat startet nicht (Architektur zu neu für die Engine?):" + tail -3 /tmp/pruefstand-server.log + echo "{\"status\":\"lade_crash\"}" > "$STATE/done/${KEY}.json" + mv "$SPEC" "$STATE/done/${KEY}.spec.json" + rm -rf "${CACHE:?}/$KEY" + briefkasten "Prüfstand" "Kandidat ${HF_ID} startet auf unserer Engine nicht (Lade-Crash) — aussortiert." + return 0 + fi + + # ---- Das volle Programm + local OUT="$STATE/ergebnisse/kandidat-${KEY}.json" + echo "Fahre Suiten: $(suiten_fuer "$ROLLE") (Zeitbudget ${DEADLINE_MIN} min)" + python3 "$PSD/harness.py" --endpoint "http://127.0.0.1:$BENCH_PORT/v1" \ + --model "kandidat" --suites "$(suiten_fuer "$ROLLE")" --out "$OUT" \ + --judge-endpoint "$LIVE" --judge-model "$JUDGE_MODEL" \ + --deadline-min "$DEADLINE_MIN" 2>&1 | tail -8 + kill $SPID 2>/dev/null; wait $SPID 2>/dev/null; trap - RETURN + + # ---- Steckbrief: Kandidat vs. Amtsinhaber + local BERICHT="$STATE/ergebnisse/kandidat-${KEY}.md" + python3 - "$OUT" "$STATE/baseline.json" "$ROLLE" "$HF_ID" "$WARUM" "$BERICHT" <<'PY' +import json, sys, time +kand = json.load(open(sys.argv[1])) +try: + basis = json.load(open(sys.argv[2]))["rollen"].get(sys.argv[3], {}) +except Exception: + basis = {} +rolle, hf_id, warum, ziel = sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6] +kz = kand.get("zusammenfassung", {}) +zeilen = [f"# Prüfstand-Steckbrief: {hf_id}", + f"Rolle: **{rolle}** · erhoben {time.strftime('%d.%m.%Y %H:%M')}", + f"Anlass: {warum}" if warum else "", "", + "| Suite | Kandidat | Amtsinhaber |", "|---|---|---|"] +punkte_k = punkte_b = faelle = 0 +for name in sorted(set(kz) | set(basis)): + def fmt(s): + if not s: return "—" + t = f"{s['bestanden']}/{s['gesamt']}" + if s.get("note_mittel"): t += f" · Note {s['note_mittel']}" + if s.get("tg_mittel"): t += f" · tg {s['tg_mittel']}" + if s.get("pp_mittel"): t += f" · pp {s['pp_mittel']}" + return t + zeilen.append(f"| {name} | {fmt(kz.get(name))} | {fmt(basis.get(name))} |") + if name in kz and name in basis and name != "speed": + faelle += 1 + punkte_k += kz[name]["bestanden"] / max(1, kz[name]["gesamt"]) + punkte_b += basis[name]["bestanden"] / max(1, basis[name]["gesamt"]) +tg_k = (kz.get("speed") or {}).get("tg_mittel") or 0 +tg_b = (basis.get("speed") or {}).get("tg_mittel") or 0 +zeilen.append("") +if faelle: + qk, qb = punkte_k / faelle, punkte_b / faelle + tempo = (f"Tempo {round(100*tg_k/tg_b-100):+d} % vs. Amtsinhaber" if tg_k and tg_b + else "Tempo-Vergleich unvollständig") + if qk >= qb and tg_k >= 0.8 * (tg_b or tg_k): + urteil = "KANDIDAT SIEHT STARK AUS — Karte prüfen lohnt sich." + elif qk >= qb: + urteil = "Qualität hält mit, aber deutlich langsamer — vermutlich kein Aufsteiger." + else: + urteil = "Qualität unter Amtsinhaber — kein Aufsteiger." + zeilen.append(f"**Einordnung:** Bestehensquote {qk:.0%} vs. {qb:.0%} · {tempo}. {urteil}") +zeilen.append("") +zeilen.append("_Der Prüfstand empfiehlt nur — der Rollen-Wechsel bleibt Commander-Entscheid (Karte)._") +text = "\n".join(z for z in zeilen if z is not None) +open(ziel, "w", encoding="utf-8").write(text + "\n") +print(text) +PY + + # ---- Vault-Bericht + Karte + stille Chronik, dann Cache aufräumen + mkdir -p "$VAULT/pruefstand" + cp "$BERICHT" "$VAULT/pruefstand/${KEY}.md" 2>/dev/null || true + ( cd "$VAULT" 2>/dev/null && git add "pruefstand/${KEY}.md" >/dev/null 2>&1 \ + && git commit -q -m "pruefstand: Steckbrief ${KEY}" >/dev/null 2>&1 ) || true + "$HERMES" kanban create "Prüfstand-Ergebnis: ${HF_ID} für Rolle ${ROLLE}" \ + --body "$(cat "$BERICHT")" \ + --triage --created-by pruefstand --idempotency-key "pruefstand-${KEY}" >/dev/null 2>&1 || true + briefkasten "Prüfstand" "Kandidat ${HF_ID} (Rolle ${ROLLE}) geprüft — Steckbrief im Auftrags-Kanban und Vault (pruefstand/${KEY}.md)." + mv "$SPEC" "$STATE/done/${KEY}.spec.json" 2>/dev/null || true + cp "$OUT" "$STATE/done/${KEY}.json" 2>/dev/null || echo "{\"status\":\"geprueft\"}" > "$STATE/done/${KEY}.json" + rm -rf "${CACHE:?}/$KEY" + echo + echo "(Cache aufgeräumt; Roh-Ergebnisse: $STATE/ergebnisse/kandidat-${KEY}.*)" +} + +# ---------------------------------------------------------------- Einstieg +case "${1:-}" in + --baseline) + baseline_lauf + ;; + --kandidat) + [ -n "${2:-}" ] || { echo "Nutzung: pruefstand.sh --kandidat "; exit 1; } + kandidat_lauf "$2" + ;; + *) + if [ ! -f "$STATE/baseline.json" ]; then + baseline_lauf + exit 0 + fi + NAECHSTER="$(ls -1tr "$STATE/queue/"*.json 2>/dev/null | head -1 || true)" + if [ -n "$NAECHSTER" ]; then + kandidat_lauf "$NAECHSTER" + fi + # keine Queue → LEERER stdout → --no-agent-Cron bleibt still (bewusst) + ;; +esac diff --git a/deploy/pruefstand/suites/agent/aufgaben/01-todos-sammeln/task.json b/deploy/pruefstand/suites/agent/aufgaben/01-todos-sammeln/task.json new file mode 100644 index 0000000..21ecdcf --- /dev/null +++ b/deploy/pruefstand/suites/agent/aufgaben/01-todos-sammeln/task.json @@ -0,0 +1,13 @@ +{ + "prompt": "Im Arbeitsverzeichnis liegen Notiz-Dateien im Ordner `notizen/`. Sammle ALLE Zeilen, die mit `TODO:` beginnen, und schreibe sie (ohne das `TODO:`-Präfix, eine je Zeile, Reihenfolge egal) in eine neue Datei `offene-punkte.txt` im Wurzelverzeichnis. Rufe danach `fertig` auf.", + "max_schritte": 8, + "sandkasten": { + "notizen/montag.txt": "Besprechung war ok.\nTODO: Backup-Log prüfen\nSonst nichts.\n", + "notizen/dienstag.txt": "TODO: Firmware-Update Router\nWetter schlecht.\n", + "notizen/mittwoch.txt": "Nichts offen heute.\n" + }, + "checks": [ + {"datei": "offene-punkte.txt", "regex": "Backup-Log prüfen"}, + {"datei": "offene-punkte.txt", "regex": "Firmware-Update Router"} + ] +} diff --git a/deploy/pruefstand/suites/agent/aufgaben/02-konfig-reparieren/task.json b/deploy/pruefstand/suites/agent/aufgaben/02-konfig-reparieren/task.json new file mode 100644 index 0000000..63e52c4 --- /dev/null +++ b/deploy/pruefstand/suites/agent/aufgaben/02-konfig-reparieren/task.json @@ -0,0 +1,13 @@ +{ + "prompt": "Die Datei `dienst/config.ini` ist kaputt. In `anleitung.txt` steht, welche Werte gelten sollen. Repariere die config.ini entsprechend (nur die falschen Werte ändern, Struktur beibehalten) und rufe danach `fertig` auf.", + "max_schritte": 8, + "sandkasten": { + "anleitung.txt": "Sollwerte für dienst/config.ini:\n- port muss 9001 sein (Zahl, nicht Text!)\n- loglevel muss info sein\n- Der Eintrag host bleibt unverändert.\n", + "dienst/config.ini": "[server]\nhost = 127.0.0.1\nport = abc\nloglevel = debug\n" + }, + "checks": [ + {"datei": "dienst/config.ini", "regex": "^port\\s*=\\s*9001\\s*$"}, + {"datei": "dienst/config.ini", "regex": "^loglevel\\s*=\\s*info\\s*$"}, + {"datei": "dienst/config.ini", "regex": "^host\\s*=\\s*127\\.0\\.0\\.1\\s*$"} + ] +} diff --git a/deploy/pruefstand/suites/agent/aufgaben/03-messwerte-bericht/task.json b/deploy/pruefstand/suites/agent/aufgaben/03-messwerte-bericht/task.json new file mode 100644 index 0000000..394528c --- /dev/null +++ b/deploy/pruefstand/suites/agent/aufgaben/03-messwerte-bericht/task.json @@ -0,0 +1,11 @@ +{ + "prompt": "Lies die Messwerte in `messwerte.csv` (Spalten: zeit;temperatur_c). Erstelle einen Bericht `bericht.md` mit genau zwei Zeilen: Zeile 1 `Messungen: `, Zeile 2 `Maximum: °C` (Zahl wie in der Quelldatei geschrieben). Rufe danach `fertig` auf.", + "max_schritte": 8, + "sandkasten": { + "messwerte.csv": "zeit;temperatur_c\n03:00;61,5\n04:00;72,25\n05:00;58,0\n06:00;69,75\n" + }, + "checks": [ + {"datei": "bericht.md", "regex": "Messungen:\\s*4"}, + {"datei": "bericht.md", "regex": "Maximum:\\s*72,25\\s*°?C"} + ] +} diff --git a/deploy/pruefstand/suites/agent/aufgaben/04-aufraeumen/task.json b/deploy/pruefstand/suites/agent/aufgaben/04-aufraeumen/task.json new file mode 100644 index 0000000..2f0b08f --- /dev/null +++ b/deploy/pruefstand/suites/agent/aufgaben/04-aufraeumen/task.json @@ -0,0 +1,17 @@ +{ + "prompt": "In `regeln.txt` steht, welche Dateien im Arbeitsverzeichnis gelöscht werden dürfen. Lösche GENAU die Dateien, die laut Regeln weg dürfen — nichts anderes. Rufe danach `fertig` auf.", + "max_schritte": 10, + "sandkasten": { + "regeln.txt": "Aufräum-Regeln:\n1. Alle Dateien mit Endung .tmp dürfen gelöscht werden.\n2. Dateien, deren Name mit wichtig beginnt, dürfen NIE gelöscht werden.\n3. Alles andere bleibt liegen.\n", + "cache-alt.tmp": "wegwerf-inhalt\n", + "sitzung.tmp": "wegwerf-inhalt\n", + "wichtig-vertrag.txt": "bleibt!\n", + "notiz.md": "bleibt auch.\n" + }, + "checks": [ + {"nicht_vorhanden": "cache-alt.tmp"}, + {"nicht_vorhanden": "sitzung.tmp"}, + {"datei": "wichtig-vertrag.txt", "regex": "bleibt!"}, + {"datei": "notiz.md", "regex": "bleibt auch"} + ] +} diff --git a/deploy/pruefstand/suites/coding/aufgaben/01-csv-summe/task.json b/deploy/pruefstand/suites/coding/aufgaben/01-csv-summe/task.json new file mode 100644 index 0000000..978ac56 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/01-csv-summe/task.json @@ -0,0 +1,5 @@ +{ + "prompt": "Schreibe eine Python-Funktion `summiere_umsaetze(text)` in der Datei `loesung.py`. Eingabe ist ein CSV-Text mit Kopfzeile `kategorie;betrag` — Trennzeichen Semikolon, Beträge mit DEUTSCHEM Dezimalkomma (z. B. `12,50`). Die Funktion gibt ein dict zurück, das je Kategorie die Summe der Beträge als float enthält. Leere Zeilen und Zeilen ohne Semikolon werden ignoriert; unparsebare Beträge lösen einen ValueError mit der Zeilennummer aus.", + "antwort_datei": "loesung.py", + "timeout_s": 60 +} diff --git a/deploy/pruefstand/suites/coding/aufgaben/01-csv-summe/verify.py b/deploy/pruefstand/suites/coding/aufgaben/01-csv-summe/verify.py new file mode 100644 index 0000000..aad56e9 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/01-csv-summe/verify.py @@ -0,0 +1,28 @@ +import sys +import unittest + +from loesung import summiere_umsaetze + + +class Tests(unittest.TestCase): + def test_summen(self): + text = ("kategorie;betrag\n" + "essen;12,50\n" + "essen;7,50\n" + "\n" + "strom;30,00\n") + self.assertEqual(summiere_umsaetze(text), + {"essen": 20.0, "strom": 30.0}) + + def test_ohne_semikolon_ignoriert(self): + text = "kategorie;betrag\nnur eine notizzeile\nessen;1,00\n" + self.assertEqual(summiere_umsaetze(text), {"essen": 1.0}) + + def test_kaputter_betrag(self): + with self.assertRaises(ValueError): + summiere_umsaetze("kategorie;betrag\nessen;zwoelf\n") + + +if __name__ == "__main__": + r = unittest.main(exit=False).result + sys.exit(0 if r.wasSuccessful() else 1) diff --git a/deploy/pruefstand/suites/coding/aufgaben/02-bugfix-fenster/task.json b/deploy/pruefstand/suites/coding/aufgaben/02-bugfix-fenster/task.json new file mode 100644 index 0000000..a1a6b1c --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/02-bugfix-fenster/task.json @@ -0,0 +1,8 @@ +{ + "prompt": "In der Datei `zeitfenster.py` steckt mindestens ein Fehler: `im_wartungsfenster(stunde)` soll True liefern, wenn die Stunde im nächtlichen Wartungsfenster von 23 Uhr bis einschließlich 4 Uhr liegt (also 23, 0, 1, 2, 3, 4) — für alle anderen Stunden False. Ungültige Stunden (<0 oder >23) sollen einen ValueError auslösen. Korrigiere die Datei und gib sie VOLLSTÄNDIG zurück, ohne die Funktionssignatur zu ändern.", + "antwort_datei": "zeitfenster.py", + "dateien": { + "zeitfenster.py": "def im_wartungsfenster(stunde):\n \"\"\"True, wenn die Stunde im Wartungsfenster 23-4 Uhr liegt.\"\"\"\n if stunde < 0 or stunde > 24:\n raise ValueError(\"ungueltige Stunde\")\n return 23 <= stunde or stunde < 4\n" + }, + "timeout_s": 60 +} diff --git a/deploy/pruefstand/suites/coding/aufgaben/02-bugfix-fenster/verify.py b/deploy/pruefstand/suites/coding/aufgaben/02-bugfix-fenster/verify.py new file mode 100644 index 0000000..c648b61 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/02-bugfix-fenster/verify.py @@ -0,0 +1,24 @@ +import sys +import unittest + +from zeitfenster import im_wartungsfenster + + +class Tests(unittest.TestCase): + def test_im_fenster(self): + for h in (23, 0, 1, 2, 3, 4): + self.assertTrue(im_wartungsfenster(h), f"Stunde {h}") + + def test_ausserhalb(self): + for h in (5, 6, 12, 18, 22): + self.assertFalse(im_wartungsfenster(h), f"Stunde {h}") + + def test_ungueltig(self): + for h in (-1, 24, 99): + with self.assertRaises(ValueError): + im_wartungsfenster(h) + + +if __name__ == "__main__": + r = unittest.main(exit=False).result + sys.exit(0 if r.wasSuccessful() else 1) diff --git a/deploy/pruefstand/suites/coding/aufgaben/03-log-parser/task.json b/deploy/pruefstand/suites/coding/aufgaben/03-log-parser/task.json new file mode 100644 index 0000000..02e1ba7 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/03-log-parser/task.json @@ -0,0 +1,5 @@ +{ + "prompt": "Schreibe in `loesung.py` eine Funktion `fehler_zeilen(log_text)`. Sie bekommt Logtext, in dem relevante Zeilen so aussehen: `2026-07-17T03:15:02 ERROR dienst-name: Meldungstext` (Level kann auch INFO oder WARN sein). Die Funktion gibt eine Liste von Tupeln `(zeitstempel, dienst, meldung)` NUR für ERROR-Zeilen zurück, in Original-Reihenfolge. Zeilen in anderem Format werden still ignoriert. Der Dienstname steht vor dem Doppelpunkt und enthält keine Leerzeichen.", + "antwort_datei": "loesung.py", + "timeout_s": 60 +} diff --git a/deploy/pruefstand/suites/coding/aufgaben/03-log-parser/verify.py b/deploy/pruefstand/suites/coding/aufgaben/03-log-parser/verify.py new file mode 100644 index 0000000..d755801 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/03-log-parser/verify.py @@ -0,0 +1,29 @@ +import sys +import unittest + +from loesung import fehler_zeilen + +LOG = """2026-07-17T03:15:02 ERROR llama-swap: Modell nicht gefunden +2026-07-17T03:15:03 INFO mc2: Start ok +kaputte zeile ohne format +2026-07-17T03:16:10 WARN voice: Latenz hoch +2026-07-17T03:17:00 ERROR mem0-service: Timeout nach 30s +""" + + +class Tests(unittest.TestCase): + def test_nur_error(self): + erg = fehler_zeilen(LOG) + self.assertEqual(len(erg), 2) + self.assertEqual(erg[0][0], "2026-07-17T03:15:02") + self.assertEqual(erg[0][1], "llama-swap") + self.assertEqual(erg[0][2], "Modell nicht gefunden") + self.assertEqual(erg[1][1], "mem0-service") + + def test_leer(self): + self.assertEqual(fehler_zeilen("nur INFO hier\n"), []) + + +if __name__ == "__main__": + r = unittest.main(exit=False).result + sys.exit(0 if r.wasSuccessful() else 1) diff --git a/deploy/pruefstand/suites/coding/aufgaben/04-lager-klasse/task.json b/deploy/pruefstand/suites/coding/aufgaben/04-lager-klasse/task.json new file mode 100644 index 0000000..35e9d3d --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/04-lager-klasse/task.json @@ -0,0 +1,5 @@ +{ + "prompt": "Schreibe in `loesung.py` eine Klasse `Lager`. Konstruktor ohne Argumente. Methoden: `einlagern(artikel, menge)` (menge > 0, sonst ValueError), `entnehmen(artikel, menge)` (löst ValueError aus, wenn der Bestand nicht reicht oder der Artikel unbekannt ist), `bestand(artikel)` (0 für unbekannte Artikel). Bestände sind ganze Zahlen je Artikelname.", + "antwort_datei": "loesung.py", + "timeout_s": 60 +} diff --git a/deploy/pruefstand/suites/coding/aufgaben/04-lager-klasse/verify.py b/deploy/pruefstand/suites/coding/aufgaben/04-lager-klasse/verify.py new file mode 100644 index 0000000..6d9fd78 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/04-lager-klasse/verify.py @@ -0,0 +1,31 @@ +import sys +import unittest + +from loesung import Lager + + +class Tests(unittest.TestCase): + def test_ablauf(self): + l = Lager() + l.einlagern("kabel", 10) + l.entnehmen("kabel", 4) + self.assertEqual(l.bestand("kabel"), 6) + self.assertEqual(l.bestand("unbekannt"), 0) + + def test_unterbestand(self): + l = Lager() + l.einlagern("kabel", 2) + with self.assertRaises(ValueError): + l.entnehmen("kabel", 3) + with self.assertRaises(ValueError): + l.entnehmen("gibtsnicht", 1) + + def test_menge_positiv(self): + l = Lager() + with self.assertRaises(ValueError): + l.einlagern("kabel", 0) + + +if __name__ == "__main__": + r = unittest.main(exit=False).result + sys.exit(0 if r.wasSuccessful() else 1) diff --git a/deploy/pruefstand/suites/coding/aufgaben/05-flatten/task.json b/deploy/pruefstand/suites/coding/aufgaben/05-flatten/task.json new file mode 100644 index 0000000..8d18f4c --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/05-flatten/task.json @@ -0,0 +1,5 @@ +{ + "prompt": "Schreibe in `loesung.py` eine Funktion `flach(d, praefix=\"\")`, die ein beliebig tief verschachteltes dict (Werte: dicts oder Skalare) in ein flaches dict verwandelt. Schlüsselpfade werden mit Punkt verbunden, z. B. macht `{\"a\": {\"b\": 1}, \"c\": 2}` daraus `{\"a.b\": 1, \"c\": 2}`. Leere dicts verschwinden ersatzlos. Nicht-dict-Eingaben lösen einen TypeError aus.", + "antwort_datei": "loesung.py", + "timeout_s": 60 +} diff --git a/deploy/pruefstand/suites/coding/aufgaben/05-flatten/verify.py b/deploy/pruefstand/suites/coding/aufgaben/05-flatten/verify.py new file mode 100644 index 0000000..5d55d72 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/05-flatten/verify.py @@ -0,0 +1,22 @@ +import sys +import unittest + +from loesung import flach + + +class Tests(unittest.TestCase): + def test_verschachtelt(self): + self.assertEqual(flach({"a": {"b": 1, "c": {"d": 2}}, "e": 3}), + {"a.b": 1, "a.c.d": 2, "e": 3}) + + def test_leere_dicts(self): + self.assertEqual(flach({"a": {}, "b": 1}), {"b": 1}) + + def test_typfehler(self): + with self.assertRaises(TypeError): + flach([1, 2]) + + +if __name__ == "__main__": + r = unittest.main(exit=False).result + sys.exit(0 if r.wasSuccessful() else 1) diff --git a/deploy/pruefstand/suites/coding/aufgaben/06-slugify/task.json b/deploy/pruefstand/suites/coding/aufgaben/06-slugify/task.json new file mode 100644 index 0000000..63cc997 --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/06-slugify/task.json @@ -0,0 +1,5 @@ +{ + "prompt": "Schreibe in `loesung.py` eine Funktion `slug(text)`: wandelt deutschen Text in einen URL-Slug. Regeln: Kleinbuchstaben; ä→ae, ö→oe, ü→ue, ß→ss (auch Großbuchstaben-Varianten); alle anderen Nicht-Alphanumerischen Zeichen werden zu einzelnen Bindestrichen zusammengefasst; keine Bindestriche am Anfang/Ende. Beispiel: `Größte \"Änderung\" 2026!` → `groesste-aenderung-2026`.", + "antwort_datei": "loesung.py", + "timeout_s": 60 +} diff --git a/deploy/pruefstand/suites/coding/aufgaben/06-slugify/verify.py b/deploy/pruefstand/suites/coding/aufgaben/06-slugify/verify.py new file mode 100644 index 0000000..9e3a7ec --- /dev/null +++ b/deploy/pruefstand/suites/coding/aufgaben/06-slugify/verify.py @@ -0,0 +1,21 @@ +import sys +import unittest + +from loesung import slug + + +class Tests(unittest.TestCase): + def test_beispiel(self): + self.assertEqual(slug('Größte "Änderung" 2026!'), + "groesste-aenderung-2026") + + def test_umlaute(self): + self.assertEqual(slug("Müßiggänger öfter"), "muessiggaenger-oefter") + + def test_raender(self): + self.assertEqual(slug(" --Hallo Welt-- "), "hallo-welt") + + +if __name__ == "__main__": + r = unittest.main(exit=False).result + sys.exit(0 if r.wasSuccessful() else 1) diff --git a/deploy/pruefstand/suites/deutsch/aufgaben.json b/deploy/pruefstand/suites/deutsch/aufgaben.json new file mode 100644 index 0000000..0cccb33 --- /dev/null +++ b/deploy/pruefstand/suites/deutsch/aufgaben.json @@ -0,0 +1,19 @@ +[ + { + "id": "alltag-erklaerung", + "prompt": "Du bist eine deutsche Sprach-Assistentin. Dein Mensch fragt beim Frühstück: »Sag mal, warum wird mein Handy-Akku im Winter eigentlich so schnell leer?« Antworte gesprochen, locker, in 2–3 kurzen Sätzen, Du-Form." + }, + { + "id": "schlechte-nachricht", + "prompt": "Du bist eine deutsche Sprach-Assistentin. Du musst deinem Menschen sagen, dass das nächtliche Backup fehlgeschlagen ist, aber kein Datenverlust entstanden ist und du es um 9 Uhr erneut versuchst. Sag es gesprochen, ruhig und ohne Technik-Kauderwelsch, in 2–3 Sätzen, Du-Form." + }, + { + "id": "terminplanung", + "prompt": "Du bist eine deutsche Sprach-Assistentin. Dein Mensch sagt: »Leg mir bitte was mit Zahnarzt nächste Woche Dienstag Nachmittag an, so gegen drei.« Bestätige gesprochen und natürlich in 1–2 Sätzen, Du-Form, und nenne dabei Wochentag und Uhrzeit.", + "hinweis_fuer_richter": "Muss Dienstag und 15 Uhr / drei Uhr nachmittags korrekt bestätigen." + }, + { + "id": "smalltalk-wetter", + "prompt": "Du bist eine deutsche Sprach-Assistentin. Dein Mensch kommt durchnässt rein und sagt nur: »Boah, sag nichts.« Reagiere gesprochen, warm und mit leichtem Humor, in 1–2 Sätzen, Du-Form — ohne aufdringlich zu sein." + } +] diff --git a/deploy/pruefstand/suites/recherche/aufgaben/01-zeitserver.json b/deploy/pruefstand/suites/recherche/aufgaben/01-zeitserver.json new file mode 100644 index 0000000..a3cce92 --- /dev/null +++ b/deploy/pruefstand/suites/recherche/aufgaben/01-zeitserver.json @@ -0,0 +1,6 @@ +{ + "id": "zeitserver-ausfall", + "frage": "Was passiert, wenn der Zeitserver der Werkhalle länger ausfällt, und wie heißt er?", + "muss_gruppen": [["chronos-w"], ["Haltemodus"], ["90"]], + "zitat_pflicht": true +} diff --git a/deploy/pruefstand/suites/recherche/aufgaben/02-band-schluessel.json b/deploy/pruefstand/suites/recherche/aufgaben/02-band-schluessel.json new file mode 100644 index 0000000..8a15839 --- /dev/null +++ b/deploy/pruefstand/suites/recherche/aufgaben/02-band-schluessel.json @@ -0,0 +1,6 @@ +{ + "id": "band-schluessel", + "frage": "Warum kann ein Dieb mit den Sicherungsbändern aus Nordhof nichts anfangen?", + "muss_gruppen": [["AES-256", "AES"], ["Schlüsseltresor", "Verwaltung"], ["verschlüsselt"]], + "zitat_pflicht": true +} diff --git a/deploy/pruefstand/suites/recherche/aufgaben/03-pv-anteil.json b/deploy/pruefstand/suites/recherche/aufgaben/03-pv-anteil.json new file mode 100644 index 0000000..faf9b5c --- /dev/null +++ b/deploy/pruefstand/suites/recherche/aufgaben/03-pv-anteil.json @@ -0,0 +1,6 @@ +{ + "id": "pv-anteil", + "frage": "Wie viel Strom lieferte die Photovoltaik-Anlage im zweiten Quartal und welchen Anteil am Verbrauch deckte das?", + "muss_gruppen": [["96"], ["Viertel", "25", "24", "23"], ["412"]], + "zitat_pflicht": true +} diff --git a/deploy/pruefstand/suites/recherche/aufgaben/04-quer-wartung.json b/deploy/pruefstand/suites/recherche/aufgaben/04-quer-wartung.json new file mode 100644 index 0000000..f4c56f1 --- /dev/null +++ b/deploy/pruefstand/suites/recherche/aufgaben/04-quer-wartung.json @@ -0,0 +1,6 @@ +{ + "id": "quer-wartungszugriff", + "frage": "Ein externer Techniker will nachts um 01:45 Uhr auf eine Maschinensteuerung zugreifen. Über welchen Weg muss er gehen, und warum ist dieser Zeitpunkt zusätzlich ungünstig?", + "muss_gruppen": [["falke"], ["Zwei-Faktor", "Zwei Faktor", "2-Faktor", "zweifaktor"], ["01:30", "Vollsicherung", "Sicherung"]], + "zitat_pflicht": true +} diff --git a/deploy/pruefstand/suites/recherche/dokumente/backup-konzept.md b/deploy/pruefstand/suites/recherche/dokumente/backup-konzept.md new file mode 100644 index 0000000..37c9db0 --- /dev/null +++ b/deploy/pruefstand/suites/recherche/dokumente/backup-konzept.md @@ -0,0 +1,17 @@ +# Backup-Konzept Weststadt-Werkhalle (Auszug) + +Gesichert wird dreistufig. Stufe 1: stündliche Schnappschüsse der +Leitstand-Datenbank, Aufbewahrung 48 Stunden. Stufe 2: nächtliche +Vollsicherung aller Server um 01:30 Uhr auf den Sicherungsknoten arche-3 +im Keller der Halle. Stufe 3: wöchentliche Auslagerung verschlüsselter +Sicherungsbänder in den Standort Nordhof, jeden Donnerstag per Kurier. + +Die Rücksicherung der Leitstand-Datenbank dauert im Normalfall unter +20 Minuten. Eine vollständige Wiederherstellung aller Server aus arche-3 +wurde zuletzt im Frühjahr geprobt und benötigte sechseinhalb Stunden. + +Wichtig: Die Sicherungsbänder für Nordhof werden mit dem Verfahren +AES-256 verschlüsselt; die Schlüssel liegen NICHT in der Halle, sondern +im Schlüsseltresor der Verwaltung. Ohne Rückholung des Schlüssels aus +der Verwaltung ist eine Band-Rücksicherung unmöglich — das ist die +bewusste Absicherung gegen Diebstahl der Bänder samt Halle-Infrastruktur. diff --git a/deploy/pruefstand/suites/recherche/dokumente/energie-bericht.md b/deploy/pruefstand/suites/recherche/dokumente/energie-bericht.md new file mode 100644 index 0000000..c49241c --- /dev/null +++ b/deploy/pruefstand/suites/recherche/dokumente/energie-bericht.md @@ -0,0 +1,16 @@ +# Energie-Quartalsbericht Weststadt-Werkhalle (Auszug) + +Der Stromverbrauch der Halle lag im zweiten Quartal bei 412 Megawattstunden +und damit acht Prozent unter dem Vorjahresquartal. Haupttreiber der +Einsparung war die Umrüstung der Hallenbeleuchtung auf gesteuerte +LED-Felder, die nur bei Anwesenheit auf voller Stufe laufen. + +Die Photovoltaik-Anlage auf dem Süddach lieferte 96 Megawattstunden und +deckte damit knapp ein Viertel des Verbrauchs. An sonnenreichen Wochenenden +speist die Halle Überschüsse ins Netz zurück; die Vergütung dafür wird im +Jahresabschluss gesondert ausgewiesen. + +Für das dritte Quartal ist die Inbetriebnahme des Batteriespeichers +(Kapazität 180 Kilowattstunden) geplant. Er soll Lastspitzen der großen +Pressen abfangen, die bisher teure Spitzenlast-Tarife auslösen. Die +Wirtschaftlichkeitsrechnung erwartet eine Amortisation nach vier Jahren. diff --git a/deploy/pruefstand/suites/recherche/dokumente/netzwerk-handbuch.md b/deploy/pruefstand/suites/recherche/dokumente/netzwerk-handbuch.md new file mode 100644 index 0000000..fc4d4d9 --- /dev/null +++ b/deploy/pruefstand/suites/recherche/dokumente/netzwerk-handbuch.md @@ -0,0 +1,17 @@ +# Netzwerk-Handbuch Weststadt-Werkhalle (Auszug) + +Die Werkhalle Weststadt betreibt zwei getrennte Netze: das Betriebsnetz +(10.40.0.0/16) für Maschinensteuerungen und das Büronetz (192.168.20.0/24) +für Arbeitsplätze. Ein Übergang zwischen beiden Netzen existiert nur über +die Koppelstelle KS-2, die jede Verbindung protokolliert. + +Der zentrale Zeitserver der Halle heißt chronos-w und steht im Betriebsnetz. +Alle Steuerungen gleichen ihre Uhren viertelstündlich mit chronos-w ab. +Fällt der Zeitserver länger als 90 Minuten aus, wechseln die Steuerungen in +den Haltemodus und müssen einzeln von Hand quittiert werden. + +Für Wartungszugriffe von außen gilt: Zugang ausschließlich über das +Sprungsystem falke, das eine Zwei-Faktor-Anmeldung verlangt. Direkte +Zugriffe aus dem Büronetz auf Steuerungen sind technisch gesperrt. +Die Sperrliste pflegt das Team Leitstand, Änderungen brauchen zwei +Freigaben und werden erst nach dem Wochenwechsel wirksam. diff --git a/deploy/pruefstand/suites/vision/gen_testbild.py b/deploy/pruefstand/suites/vision/gen_testbild.py new file mode 100644 index 0000000..137fc42 --- /dev/null +++ b/deploy/pruefstand/suites/vision/gen_testbild.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# Erzeugt das Prüfstand-Testbild deterministisch OHNE Zusatz-Pakete (reines +# zlib/struct-PNG): weißer Grund 256x256, rotes Rechteck links oben, +# blaues Rechteck rechts unten. Die Vision-Suite prüft genau diese Aussagen. +import struct +import sys +import zlib + +B, H = 256, 256 + + +def pixel(x, y): + if 24 <= x < 104 and 24 <= y < 104: + return (220, 30, 30) # rotes Rechteck links oben + if 152 <= x < 232 and 152 <= y < 232: + return (30, 60, 220) # blaues Rechteck rechts unten + return (255, 255, 255) + + +def chunk(typ, daten): + c = typ + daten + return struct.pack(">I", len(daten)) + c + struct.pack(">I", zlib.crc32(c)) + + +roh = b"" +for y in range(H): + roh += b"\x00" + b"".join(bytes(pixel(x, y)) for x in range(B)) + +png = (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", B, H, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(roh, 9)) + + chunk(b"IEND", b"")) + +ziel = sys.argv[1] if len(sys.argv) > 1 else "testbild.png" +with open(ziel, "wb") as f: + f.write(png) +print(f"Testbild geschrieben: {ziel}") diff --git a/deploy/pruefstand/suites/vision/task.json b/deploy/pruefstand/suites/vision/task.json new file mode 100644 index 0000000..6a4f401 --- /dev/null +++ b/deploy/pruefstand/suites/vision/task.json @@ -0,0 +1,4 @@ +{ + "prompt": "Beschreibe kurz auf Deutsch, was auf diesem Bild zu sehen ist: welche Formen, welche Farben, und wo sie ungefähr liegen.", + "muss_gruppen": [["rot"], ["blau"], ["links oben", "oben links", "obere linke", "links-oben"], ["rechts unten", "unten rechts", "untere rechte", "rechts-unten"]] +} diff --git a/deploy/trend-radar-feed.sh b/deploy/trend-radar-feed.sh new file mode 100644 index 0000000..cc73fe8 --- /dev/null +++ b/deploy/trend-radar-feed.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +# Trend-Radar (wöchentlich Sa 05:15, 17.07.2026): hält das MODELL- UND ENGINE-GEFÜGE +# der Box aktuell im Blick — für ALLE Rollen (hermes/coder/heavy/vision/scout/embed), +# nicht nur das Hirn. Vier Schritte, alles deterministisch im Skript, der Agent ist +# nur der Bote: +# 1. LIVE-PULS: echte Tempo-Messung jeder Rolle über llama-swap (:8080), Vergleich +# zur Vorwoche (State), Regressionen >10 % werden gemeldet. +# 2. ENGINE-A/B: gibt es einen neueren llama.cpp-Vulkan-Build, wird er nach /tmp +# geladen und auf dem Bench-Port GEGEN den installierten Build gemessen — +# installiert wird weiterhin NICHTS (Update bleibt Button/Entscheid). +# 3. WATCH-LISTE: mechanisch prüfbare Bedingungen (Issue zu? neuer ROCm-Release? +# Community-Grid-Zahlen) aus deploy/trend-radar-watchlist.json. +# 4. KANDIDATEN-SUCHE: HuggingFace-Trending (GGUF) je Rolle; ein Analyst mit +# Zitat-Gate destilliert MAX. 1 Prüfstand-Kandidaten pro Woche → Spec in die +# Prüfstand-Queue (So 01:00 prüft ihn mit dem vollen Programm). +# Der Radar EMPFIEHLT NUR — Rollen-Wechsel, Engine-Updates und Adoptionen entscheidet +# der Commander (Karten-Klick). Muster wie idle-radar/release-radar: Temp-Dateien statt +# Pipe+Heredoc (Lehre 10.07.), Ein-Schuss-Analyst gegen :8080, stiller Briefkasten. +set -uo pipefail + +MC="$HOME/mission-control-v2" +STATE_DIR="$HOME/.hermes/state" +STATE="$STATE_DIR/trend-radar-state.json" +PS_QUEUE="$STATE_DIR/pruefstand/queue" +PS_DONE="$STATE_DIR/pruefstand/done" +WATCHLIST="$MC/deploy/trend-radar-watchlist.json" +ENDPOINT="${RADAR_ENDPOINT:-http://127.0.0.1:8080/v1}" +ANALYST="${RADAR_MODEL:-gpt-oss-120b}" +VERTRETUNG="${RADAR_FALLBACK:-GLM-4.7-Flash}" +BRAIN_GGUF="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}" +BENCH_PORT="${BENCH_PORT:-5899}" +VULKAN_DIR=/opt/llamacpp-vulkan + +mkdir -p "$STATE_DIR" "$PS_QUEUE" "$PS_DONE" +[ -f "$STATE" ] || echo '{}' > "$STATE" +TMPD="$(mktemp -d /tmp/trend-radar.XXXXXX)" +trap 'rm -rf "$TMPD"' EXIT + +briefkasten() { # $1=Betreff $2=Text + curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \ + -H 'Content-Type: application/json' \ + --data "$(python3 - "$1" "$2" <<'PY' +import json, sys +print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1], + "source": "trend-radar", "priority": "silent"})) +PY +)" >/dev/null 2>&1 || true +} + +# ---------- Versionierter Auftrag für den Boten-Agenten ---------- +cat "$MC/deploy/trend-radar-prompt.md" 2>/dev/null || cat <<'EOF' +(Prompt-Datei fehlt noch — Kurzform:) Du bist der Bote des wöchentlichen Trend-Radars. +Berichte den Befund unten in Lucys Stimme in höchstens 6 Sätzen: Puls-Auffälligkeiten, +Engine-A/B-Ergebnis, Watch-Treffer, ob ein Prüfstand-Kandidat eingereiht wurde. +Nichts selbst umsetzen — das Skript hat alles Nötige bereits getan. +EOF +echo +echo "## TREND-RADAR-BEFUND (erhoben am $(date '+%d.%m.%Y %H:%M'))" +echo + +# ===================================================================== +# 1. LIVE-PULS aller Rollen (heavy zuletzt → Analyst findet es noch warm) +# ===================================================================== +echo "### 1. LIVE-PULS (echte Messung über llama-swap, Vergleich zur Vorwoche)" +python3 - "$ENDPOINT" "$STATE" "$TMPD/puls.json" <<'PY' +import json, sys, time, urllib.request + +endpoint, state_pfad, out_pfad = sys.argv[1], sys.argv[2], sys.argv[3] +ROLLEN = ["hermes", "coder", "vision", "scout", "heavy"] # heavy zuletzt (Analyst-Wärme) + +def chat(model, timeout=420): + body = {"model": model, "max_tokens": 160, "temperature": 0, "messages": [ + {"role": "user", "content": + "Erkläre in drei kurzen Sätzen, was ein Mixture-of-Experts-Modell ist."}]} + req = urllib.request.Request(endpoint.rstrip("/") + "/chat/completions", + data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.load(r).get("timings") or {} + +puls = {} +for rolle in ROLLEN: + try: + chat(rolle) # Lauf 1: lädt/wärmt (on-demand-Modelle brauchen Minuten) + t = chat(rolle) # Lauf 2: die eigentliche Messung + puls[rolle] = {"tg": round(t.get("predicted_per_second") or 0, 1), + "pp": round(t.get("prompt_per_second") or 0, 1)} + except Exception as e: + puls[rolle] = {"fehler": f"{type(e).__name__}"} +# embed: Funktions- und Latenz-Probe +try: + t0 = time.time() + req = urllib.request.Request(endpoint.rstrip("/") + "/embeddings", + data=json.dumps({"model": "embed", "input": "Puls-Probe"}).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=60) as r: + json.load(r) + puls["embed"] = {"ms": round((time.time() - t0) * 1000)} +except Exception as e: + puls["embed"] = {"fehler": f"{type(e).__name__}"} + +try: + alt = json.load(open(state_pfad)).get("puls") or {} +except Exception: + alt = {} +for rolle, w in puls.items(): + zeile = f"- {rolle}: " + if "fehler" in w: + zeile += f"MESSUNG FEHLGESCHLAGEN ({w['fehler']})" + elif "ms" in w: + zeile += f"{w['ms']} ms" + else: + zeile += f"tg {w['tg']} t/s · pp {w['pp']} t/s" + a = alt.get(rolle) or {} + if a.get("tg") and w.get("tg"): + d = 100 * w["tg"] / a["tg"] - 100 + zeile += f" · Vorwoche {a['tg']} ({d:+.0f} %)" + if d <= -10: + zeile += " ⚠️ REGRESSION" + print(zeile) +json.dump(puls, open(out_pfad, "w")) +PY +PULS_BLOCK="$(cat "$TMPD/puls.json" 2>/dev/null || echo '{}')" +echo + +# ===================================================================== +# 2. ENGINE-A/B (nur wenn ein neuerer Vulkan-Build existiert) +# ===================================================================== +echo "### 2. ENGINE-A/B (neuer llama.cpp-Build gegen installierten, Hirn-GGUF, Bench-Port)" +ENGINE_BLOCK="kein Vergleich gelaufen" +INST_NUM="$(LD_LIBRARY_PATH=$VULKAN_DIR $VULKAN_DIR/llama-server --version 2>&1 \ + | grep -o 'version: [0-9]*' | grep -o '[0-9]*' || true)" +ASSET_RX='ubuntu-vulkan-x64\.tar\.gz$' +REL_JSON="$(curl -sf --max-time 30 'https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=15' || true)" +NEU_TAG=""; NEU_URL="" +if [ -n "$REL_JSON" ]; then + NEU_URL="$(printf '%s' "$REL_JSON" | jq -r --arg rx "$ASSET_RX" \ + '[.[] | .assets[]? | select(.name|test($rx))][0].browser_download_url // empty' 2>/dev/null || true)" + NEU_TAG="$(printf '%s' "$REL_JSON" | jq -r --arg rx "$ASSET_RX" \ + '[.[] | select(any(.assets[]?; .name|test($rx)))][0].tag_name // empty' 2>/dev/null || true)" +fi +NEU_NUM="$(printf '%s' "$NEU_TAG" | grep -o '[0-9]*' | head -1 || true)" +MEM_FREI_GB="$(awk '/MemAvailable/{printf "%d", $2/1048576}' /proc/meminfo)" + +if [ -z "$INST_NUM" ] || [ -z "$NEU_NUM" ]; then + ENGINE_BLOCK="Versionsermittlung unvollständig (installiert: ${INST_NUM:-?}, GitHub: ${NEU_NUM:-?}) — ehrlich: kein Vergleich." +elif [ "$NEU_NUM" -le "$INST_NUM" ]; then + ENGINE_BLOCK="installiert b${INST_NUM} = aktuellster Build mit Vulkan-Paket (b${NEU_NUM}) — nichts zu messen." +elif [ "${MEM_FREI_GB:-0}" -lt 30 ]; then + ENGINE_BLOCK="neuer Build b${NEU_NUM} verfügbar, aber nur ${MEM_FREI_GB} GB RAM frei — A/B verschoben." +elif [ ! -f "$BRAIN_GGUF" ]; then + ENGINE_BLOCK="neuer Build b${NEU_NUM} verfügbar, aber Hirn-GGUF nicht unter ${BRAIN_GGUF} — Pfad prüfen (BRAIN_GGUF)." +else + mkdir -p "$TMPD/engine" + if curl -fsSL --max-time 600 -o "$TMPD/engine/neu.tgz" "$NEU_URL" \ + && tar xzf "$TMPD/engine/neu.tgz" -C "$TMPD/engine"; then + NEU_DIR="$(echo "$TMPD"/engine/llama-*/ | awk '{print $1}')" + ab_bench() { # $1=bin-dir → mittlere tg (2 Messläufe nach Warmlauf) oder "crash" + local BD="${1%/}" + LD_LIBRARY_PATH="$BD" "$BD/llama-server" -m "$BRAIN_GGUF" --host 127.0.0.1 \ + --port "$BENCH_PORT" -c 8192 -ngl 999 -fa on --no-mmap --jinja \ + --spec-type draft-mtp --spec-draft-n-max 3 >/tmp/trend-radar-bench.log 2>&1 & + local PID=$! + local OK=0 + for i in $(seq 1 120); do + curl -s --max-time 2 "http://127.0.0.1:$BENCH_PORT/health" | grep -q ok && { OK=1; break; } + sleep 2 + kill -0 $PID 2>/dev/null || break + done + if [ "$OK" != "1" ]; then echo "crash"; kill $PID 2>/dev/null; wait $PID 2>/dev/null; return; fi + local SUMME=0 N=0 + for r in 0 1 2; do + curl -s --max-time 300 -X POST "http://127.0.0.1:$BENCH_PORT/completion" \ + -H "Content-Type: application/json" \ + -d '{"prompt":"Erklaere in drei kurzen Saetzen, was ein Mixture-of-Experts-Modell ist.","n_predict":150,"temperature":0}' \ + > "$TMPD/engine/probe.json" 2>/dev/null + [ "$r" = "0" ] && continue + local TG + TG="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("timings",{}).get("predicted_per_second") or 0)' "$TMPD/engine/probe.json" 2>/dev/null || echo 0)" + SUMME="$(python3 -c "print($SUMME + $TG)")"; N=$((N+1)) + done + kill $PID 2>/dev/null; wait $PID 2>/dev/null; sleep 2 + [ "$N" -gt 0 ] && python3 -c "print(round($SUMME/$N,1))" || echo 0 + } + TG_ALT="$(ab_bench "$VULKAN_DIR")" + TG_NEU="$(ab_bench "$NEU_DIR")" + if [ "$TG_NEU" = "crash" ]; then + ENGINE_BLOCK="Kandidat b${NEU_NUM} STARTET NICHT auf dem Hirn-GGUF (Lade-Crash) — Update wäre riskant, Finger weg bis zum nächsten Build." + elif [ "$TG_ALT" = "crash" ] || [ "${TG_ALT%.*}" = "0" ]; then + ENGINE_BLOCK="Referenzmessung des installierten Builds fehlgeschlagen — kein belastbares A/B." + else + DELTA="$(python3 -c "print(round(100*$TG_NEU/$TG_ALT-100,1))" 2>/dev/null || echo '?')" + ENGINE_BLOCK="b${NEU_NUM} (neu) tg ${TG_NEU} t/s vs. b${INST_NUM} (installiert) ${TG_ALT} t/s → ${DELTA} %. Anwendung übernimmt das So-04:30-Auto-Update (Rollback-Fangnetz) bzw. der Cockpit-Knopf — bei klar NEGATIVEM Delta vorher pinnen erwägen." + fi + else + ENGINE_BLOCK="Download/Entpacken von b${NEU_NUM} fehlgeschlagen — nächste Woche neuer Versuch." + fi +fi +echo "$ENGINE_BLOCK" +echo + +# ===================================================================== +# 3. WATCH-LISTE (mechanische Bedingungen) +# ===================================================================== +echo "### 3. WATCH-LISTE (mechanisch geprüft)" +python3 - "$WATCHLIST" "$STATE" "$TMPD/watch.txt" <<'PY' +import json, sys, urllib.request + +def hole(url, timeout=30): + req = urllib.request.Request(url, headers={"User-Agent": "mc2-trend-radar"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read().decode("utf-8", "replace") + +try: + eintraege = json.load(open(sys.argv[1], encoding="utf-8")).get("eintraege", []) +except Exception as e: + print(f"(Watch-Liste nicht lesbar: {e})") + open(sys.argv[3], "w").write("") + raise SystemExit +try: + state = json.load(open(sys.argv[2])) +except Exception: + state = {} +gesehen = state.get("watch_gesehen") or {} +zeilen = [] +for e in eintraege: + key, typ = e.get("key", "?"), e.get("typ") + try: + if typ == "github_issue": + d = json.loads(hole(f"https://api.github.com/repos/{e['repo']}/issues/{e['nummer']}")) + status = d.get("state", "?") + treffer = " ⚠️ BEDINGUNG ERFÜLLT!" if status == "closed" else "" + zeilen.append(f"- {key}: Issue #{e['nummer']} ist {status}{treffer} ({e['warum']})") + elif typ == "github_release": + d = json.loads(hole(f"https://api.github.com/repos/{e['repo']}/releases?per_page=1")) + tag = (d[0].get("tag_name") if isinstance(d, list) and d else "?") or "?" + neu = " ⚠️ NEU seit letzter Woche!" if tag != gesehen.get(key) else "" + zeilen.append(f"- {key}: neuester Release {tag}{neu} ({e['warum']})") + gesehen[key] = tag + elif typ == "url_zeilen": + text = hole(e["url"], timeout=45) + passend = [z.strip() for z in text.splitlines() if e.get("muster", "") in z] + passend = passend[: int(e.get("max_zeilen", 20))] + zeilen.append(f"- {key}: {len(passend)} Messwert-Zeilen ({e['warum']}):") + zeilen += [f" {z[:180]}" for z in passend] + else: + zeilen.append(f"- {key}: unbekannter Typ {typ}") + except Exception as ex: + zeilen.append(f"- {key}: PRÜFUNG AUSGEFALLEN ({type(ex).__name__})") +state["watch_gesehen"] = gesehen +json.dump(state, open(sys.argv[2], "w")) +ausgabe = "\n".join(zeilen) or "(Watch-Liste leer)" +print(ausgabe) +open(sys.argv[3], "w", encoding="utf-8").write(ausgabe) +PY +echo + +# ===================================================================== +# 4. KANDIDATEN-SUCHE (HF-Trending → Analyst mit Zitat-Gate → Prüfstand-Queue) +# ===================================================================== +echo "### 4. KANDIDATEN-SUCHE (HuggingFace-Trending, GGUF)" +curl -sf --max-time 45 \ + 'https://huggingface.co/api/models?filter=gguf&sort=trendingScore&direction=-1&limit=40' \ + -o "$TMPD/hf.json" || echo '[]' > "$TMPD/hf.json" +python3 - "$TMPD/hf.json" "$TMPD/hf.txt" <<'PY' +import json, sys +try: + modelle = json.load(open(sys.argv[1])) +except Exception: + modelle = [] +zeilen = [] +for m in modelle if isinstance(modelle, list) else []: + mid = m.get("modelId") or m.get("id") or "?" + zeilen.append(f"- {mid} · downloads {m.get('downloads', 0)} · likes {m.get('likes', 0)}" + f" · angelegt {(m.get('createdAt') or '')[:10]}") +ausgabe = "\n".join(zeilen[:40]) or "(HF-API nicht erreichbar oder leer)" +print(ausgabe) +open(sys.argv[2], "w", encoding="utf-8").write(ausgabe) +PY +echo + +# Schon bekannt (Dedup): gemeldete Keys + Prüfstand-Queue + erledigte Prüfungen +BEKANNT="$(python3 - "$STATE" "$PS_QUEUE" "$PS_DONE" <<'PY' +import glob, json, os, sys +try: + state = json.load(open(sys.argv[1])) +except Exception: + state = {} +zeilen = [f"- {k}" for k in (state.get("kandidaten_gemeldet") or [])] +for d in (sys.argv[2], sys.argv[3]): + for f in glob.glob(os.path.join(d, "*.json")): + zeilen.append(f"- {os.path.splitext(os.path.basename(f))[0]}") +print("\n".join(sorted(set(zeilen))) or "(noch keine)") +PY +)" + +RASTER='Du bist der TREND-RADAR einer lokalen KI-Box (AMD Strix Halo, 128 GB Unified RAM, ~256 GB/s Speicherbandbreite, llama.cpp/Vulkan). Du bekommst: LIVE-PULS aller Modell-Rollen, ein Engine-A/B-Ergebnis, WATCH-LISTEN-Befunde und die HuggingFace-Trending-Liste (GGUF). ROLLEN-STECKBRIEF (Amtsinhaber und harte Anforderungen): hermes = Lucys Sprach-Hirn, Qwen3.6-35B-A3B (MoE, 3B aktiv) — Kandidaten MÜSSEN MoE mit wenigen aktiven Parametern sein, Latenz ist heilig, dichte Modelle sind NIE Hirn-Kandidaten (Bandbreiten-Physik, Verdikt 17.07.); coder = Qwen3-Coder-Next, braucht 128k+ Kontext; heavy = gpt-oss-120b (Denker, MoE); vision = Qwen3-VL-30B-A3B (braucht mmproj); scout = GLM-4.6V-Flash (klein, Vision+Tools); embed = Qwen3-Embedding-0.6B. Deine Aufgabe: destilliere AUS DEM MATERIAL (a) maximal EINEN Prüfstand-Kandidaten (nur wenn ein Trending-Modell eine Rolle PLAUSIBEL verbessern könnte und aufs RAM-Budget passt) und (b) 0-2 HINWEISE (z. B. erfüllte Watch-Bedingung, Engine-Regression, neues Beschleuniger-Verfahren). REGELN: Die hf_id des Kandidaten MUSS WOERTLICH in der HF-Liste stehen (wird mechanisch geprüft, erfundene IDs fliegen raus). Nichts vorschlagen, was unter SCHON BEKANNT steht. Reine Namens-Hypes ohne erkennbare Rollen-Passung sind KEIN Kandidat. KANDIDAT: 0 ist der Normalfall und völlig ok. Antworte auf DEUTSCH, exakt so: erste Zeile "KANDIDAT: <0|1>". Bei 1 folgen vier Zeilen: "HF_ID: " / "ROLLE: " / "BELEG: " / "WARUM: ". Danach optional: "HINWEISE:" gefolgt von maximal 2 Spiegelstrichen mit je einem Satz + wörtlichem Beleg-Zitat aus dem Material.' + +EVID="$(cat </dev/null || echo '(leer)') + +HF-TRENDING (GGUF, Top 40): +$(cat "$TMPD/hf.txt" 2>/dev/null || echo '(leer)') + +SCHON BEKANNT (nicht erneut vorschlagen): +$BEKANNT +EOF +)" + +judge() { # $1=Modell $2=Timeout $3=max_tokens + local req + req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \ + '{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}], + max_tokens:$mt, temperature:0.2}')" + curl -s -m "$2" "$ENDPOINT/chat/completions" -H 'Content-Type: application/json' \ + --data-binary "$req" | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) + msg = d["choices"][0]["message"] + print((msg.get("content") or msg.get("reasoning_content") or "").strip()) +except Exception: + pass' +} + +RICHTER="$ANALYST" +ANALYSE="$(judge "$ANALYST" 420 2600)" +if [ -z "${ANALYSE// /}" ]; then + RICHTER="$VERTRETUNG (Vertretung — $ANALYST hat nicht geantwortet)" + ANALYSE="$(judge "$VERTRETUNG" 240 1800)" +fi + +echo "### 5. ANALYSE (Analyst: $RICHTER)" +if [ -z "${ANALYSE// /}" ]; then + echo "AUSGEFALLEN — beide Analysten haben nicht geantwortet. Puls/Engine/Watch oben gelten trotzdem; dem Commander EINEN ehrlichen Satz melden." + briefkasten "Trend-Radar" "Wochenlauf: Puls/Engine/Watch erhoben, Kandidaten-Analyse ausgefallen (kein Analyst erreichbar)." + exit 0 +fi +echo "$ANALYSE" +echo + +# ---------- Zitat-Gate + Spec in die Prüfstand-Queue (deterministisch) ---------- +printf '%s' "$ANALYSE" > "$TMPD/analyse.txt" +printf '%s' "$EVID" > "$TMPD/material.txt" +ERGEBNIS="$(python3 - "$TMPD/analyse.txt" "$TMPD/material.txt" "$STATE" "$PS_QUEUE" <<'PY' +import json, os, re, sys, time, unicodedata + +ana = open(sys.argv[1], encoding="utf-8", errors="replace").read() +evid = open(sys.argv[2], encoding="utf-8", errors="replace").read() +state_pfad, queue = sys.argv[3], sys.argv[4] + +def norm(s): + s = unicodedata.normalize("NFKC", s or "") + s = "".join(ch for ch in s if ch.isalnum() or ch.isspace()) + return " ".join(s.lower().split()) + +def feld(name): + m = re.search(rf"^{name}\s*:\s*(.+)$", ana, re.MULTILINE) + return m.group(1).strip() if m else "" + +anzahl = feld("KANDIDAT") +if not anzahl.startswith("1"): + print("KEIN_KANDIDAT") + raise SystemExit +hf_id = feld("HF_ID").strip("`").strip() +rolle = feld("ROLLE").lower().strip() +beleg = feld("BELEG").strip() +warum = feld("WARUM").strip() +if rolle not in ("hermes", "coder", "heavy", "vision", "scout", "embed"): + print(f"VERWORFEN: unbekannte Rolle {rolle!r}") + raise SystemExit +if hf_id not in evid: + print(f"VERWORFEN: HF_ID {hf_id!r} steht nicht wörtlich im Material (Zitat-Gate)") + raise SystemExit +if beleg and norm(beleg) not in norm(evid): + print("VERWORFEN: BELEG nicht wörtlich im Material (Zitat-Gate)") + raise SystemExit +key = re.sub(r"[^a-z0-9-]+", "-", hf_id.lower())[:60].strip("-") +try: + state = json.load(open(state_pfad)) +except Exception: + state = {} +gemeldet = state.get("kandidaten_gemeldet") or [] +if key in gemeldet: + print(f"VERWORFEN: {key} schon früher gemeldet") + raise SystemExit +spec = {"key": key, "rolle": rolle, "hf_id": hf_id, "warum": warum, + "beleg": beleg, "erstellt": time.strftime("%Y-%m-%d %H:%M"), + "quelle": "trend-radar"} +with open(os.path.join(queue, key + ".json"), "w", encoding="utf-8") as f: + json.dump(spec, f, ensure_ascii=False, indent=1) +state["kandidaten_gemeldet"] = gemeldet + [key] +json.dump(state, open(state_pfad, "w")) +print(f"EINGEREIHT: {hf_id} → Rolle {rolle} (Prüfstand prüft in der Nacht So 01:00)") +PY +)" +echo "### 6. PRÜFSTAND-QUEUE" +echo "$ERGEBNIS" + +# ---------- Puls in den State (für den Vorwochen-Vergleich) ---------- +python3 - "$STATE" "$TMPD/puls.json" <<'PY' +import json, sys +try: + state = json.load(open(sys.argv[1])) +except Exception: + state = {} +try: + state["puls"] = json.load(open(sys.argv[2])) +except Exception: + pass +json.dump(state, open(sys.argv[1], "w")) +PY + +briefkasten "Trend-Radar" "Wochenlauf fertig. Engine: ${ENGINE_BLOCK} +Queue: ${ERGEBNIS}" diff --git a/deploy/trend-radar-prompt.md b/deploy/trend-radar-prompt.md new file mode 100644 index 0000000..fd9ecf3 --- /dev/null +++ b/deploy/trend-radar-prompt.md @@ -0,0 +1,31 @@ +# Trend-Radar — Auftrag für den Boten-Agenten + +Du bist der Bote des wöchentlichen Trend-Radars. Unten steht ein automatisch +erhobener BEFUND in sechs Abschnitten: Live-Puls aller Modell-Rollen, Engine-A/B, +Watch-Liste, HuggingFace-Trending, Analysten-Analyse und Prüfstand-Queue. Das +Skript hat alles Nötige BEREITS getan (gemessen, geprüft, ggf. einen Kandidaten +für den nächtlichen Prüfstand eingereiht). + +Deine Aufgabe — NUR berichten, in Lucys Stimme (Anrede „Commander", +Alltagssprache, Fazit zuerst), in höchstens 6 Sätzen: + +1. Puls: nur Auffälligkeiten nennen (⚠️-Zeilen: Regression oder ausgefallene + Messung); wenn alles im Rahmen ist, reicht „Tempo aller Rollen stabil". +2. Engine-A/B: Ergebnis in einem Satz (Prozent-Delta oder warum kein Vergleich + lief). Das ist die FRÜHWARNUNG vor dem So-04:30-Auto-Update: bei Lade-Crash + oder klar negativem Delta dem Commander empfehlen, die Engine vorher zu + pinnen (Wartungs-Drawer / mc2-pins.json) — sonst reicht der Messwert. +3. Watch-Liste: nur Zeilen mit „⚠️" erwähnen — eine erfüllte Bedingung ist die + wichtigste Nachricht des Berichts. +4. Kandidat: wenn einer eingereiht wurde, Modell + Rolle nennen und sagen, dass + der Prüfstand ihn nachts mit dem vollen Programm prüft und das Ergebnis als + Karte kommt; bei „KEIN_KANDIDAT" GENAU EIN kurzer Satz dazu. + +Harte Regeln: +- NICHTS selbst umsetzen: keine Updates, keine Rollen-Wechsel, keine Downloads, + keine Kanban-Aktionen — alles Entscheidende liegt beim Commander bzw. hat das + Skript schon erledigt. +- Keine Zahlen erfinden oder runden, die nicht im Befund stehen; im Zitat-Gate + VERWORFENE Kandidaten nicht als Erfolg verkaufen. +- Die stille Briefkasten-Karte hat das Skript bereits geschrieben — du lieferst + nur die kurze Telegram-Meldung. diff --git a/deploy/trend-radar-watchlist.json b/deploy/trend-radar-watchlist.json new file mode 100644 index 0000000..dff5d2f --- /dev/null +++ b/deploy/trend-radar-watchlist.json @@ -0,0 +1,29 @@ +{ + "_hinweis": "Watch-Liste des Trend-Radars (Sa 05:15) — mechanisch prüfbare Bedingungen statt Prosa-Erinnerungen. Neue Einträge einfach anhängen; der Radar prüft github_issue (offen/zu), github_release (neuer Tag) und url_zeilen (Zeilen mit Suchmuster aus einer Roh-URL).", + "eintraege": [ + { + "key": "mmq-prefill-gfx1151", + "typ": "github_issue", + "repo": "ggml-org/llama.cpp", + "nummer": 21284, + "bedingung": "Issue geschlossen = MMQ-Tuning für gfx1151 vermutlich gemerged", + "warum": "Getunte MMQ-Defaults brachten +60 % ROCm-Prefill auf 35B-MoE (unser Hirn-Typ) und +20 % auf 122B. Wenn gemerged: ROCm-Langkontext-Bench für coder fällig (Vulkan-Verdikt bleibt bis zur Messung)." + }, + { + "key": "rocm-releases", + "typ": "github_release", + "repo": "ROCm/ROCm", + "bedingung": "Neuer ROCm-Release seit letzter Meldung", + "warum": "Seit 7.14 ist TheRock der Produktions-Stack und AMD optimiert offiziell für die Halo-Familie — ROCm könnte Vulkan bei der Token-Erzeugung einholen. Bei neuem Release: Community-Grid-Zeilen unten gegenlesen." + }, + { + "key": "llamacpp-rocm-diskussion", + "typ": "url_zeilen", + "url": "https://raw.githubusercontent.com/hogeheer499-commits/strix-halo-guide/main/README.md", + "muster": "t/s", + "max_zeilen": 30, + "bedingung": "Community-Messwerte (Vulkan vs. ROCm, MTP, neue Beschleuniger) auf unserer Hardware", + "warum": "Der Strix-Halo-Community-Grid ist der ehrlichste externe Messpunkt. Auf tg-Trendwende Vulkan↔ROCm und neue Spec-Decoding-Verfahren achten." + } + ] +} diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 5f99cea..05a4bc3 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -36,6 +36,9 @@ höchstens „mach". Dieses Blatt ist NUR für den Fall, dass etwas klemmt. manuell geht's jederzeit über den Wartungs-Drawer) - **Mo 05:15** Release-Radar (Hermes-Neuerungen) · **Monatlich 1., 09:00** Monats-Review (Evolution-Radar + Selbstkritik) auf Telegram +- **Sa 05:15** Trend-Radar (misst alle Modelle, vergleicht neue Engine-Builds, sucht + bessere Modell-Kandidaten) · **So 01:00** Prüfstand (testet gefundene Kandidaten nachts + mit echten Aufgaben durch — das Ergebnis kommt als Karte, DU entscheidest über Wechsel) ## Pinnwand: eine Ebene ist „GEPINNT" — was heißt das? diff --git a/docs/wissen/STACK.md b/docs/wissen/STACK.md index 965f5cf..4691177 100644 --- a/docs/wissen/STACK.md +++ b/docs/wissen/STACK.md @@ -86,6 +86,8 @@ per Mini-Request anwärmen (direkt curlen ist zuverlässiger als warmup.sh). | 03:50 | PBS-Host-Backup der Box (`host/aibox`: mem0+hermes+vault+llamaswap.pxar) | | 04:30 | **Chef-Gutachter** (gpt-oss richtet über die autonome Arbeit → Morgenlage) | | 05:15 Mo | **Release-Radar** (hermes-Releases gegen die Eigenbau-Landkarte; erster Lauf 13.07.) | +| 05:15 Sa | **Trend-Radar** (Live-Puls ALLER Modell-Rollen + Engine-A/B gegen neuen llama.cpp-Build + Watch-Liste [ROCm/MMQ/Community-Grid] + HF-Kandidaten-Suche → max. 1 Prüfstand-Kandidat/Woche; Frühwarnung vor dem So-Auto-Update) | +| 01:00 So | **Prüfstand** (volles Programm für Modell-Kandidaten: echte Coding-/Agenten-/Recherche-/Deutsch-/Vision-Prüfungen + Tempo auf :5899 gegen die Amtsinhaber-Baseline; Download-Deckel 35 GB, Cache räumt sich selbst, Ergebnis = Karte — Rollen-Wechsel bleibt Commander-Klick) | | 07:00 | QNAP-Volume-Snapshot (7 behalten) | | 07:15 | self-smoke (Gateway/Tools/Voice/:9119; Alarm nur bei Rot) | | 08:00 | Daily-Briefing |