Trend-Radar + Pruefstand: Box beobachtet Modell-/Engine-Trends und testet Kandidaten selbst

- trend-radar-feed.sh (Cron Sa 05:15): Live-Puls ALLER Rollen mit Vorwochen-Vergleich,
  Engine-A/B gegen neuen llama.cpp-Build (Fruehwarnung vor So-Auto-Update), mechanische
  Watch-Liste (MMQ-Issue, ROCm-Releases, Community-Grid), HF-Kandidaten-Suche mit
  Zitat-Gate -> max. 1 Pruefstand-Kandidat/Woche
- pruefstand.sh + harness.py (Cron So 01:00, no-agent): volles Programm je Kandidat -
  6 Coding-Aufgaben mit echten Unit-Tests, 4 Agenten-Aufgaben (Tool-Loop + Endzustand),
  4 Recherche-Fragen mit Zitat-Ehrlichkeits-Gate, Deutsch-Richter, Vision-Testbild,
  Tempo kurz+lang; Baseline der Amtsinhaber als Vergleichsmassstab
- Leitplanken (User-Entscheid 17.07.): Download-Deckel 35 GB (drueber -> Karte),
  1 Kandidat/Woche, Cache mit Auto-Aufraeumen, RAM-/Platz-Wache, Bench-Port :5899,
  Radar/Pruefstand EMPFEHLEN nur - Rollen-Wechsel bleibt Commander-Klick
- E2E bewiesen (Mock-LLM): coding 6/6, recherche 4/4 inkl. Zitat-Gate, Agent-Roundtrip,
  Richter-Parsing; Suiten-Selbsttest mit Referenzloesungen 6/6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-17 14:56:58 +02:00
parent 8579fe9934
commit b0dce954e9
34 changed files with 1786 additions and 1 deletions
+7 -1
View File
@@ -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"
+571
View File
@@ -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: "<eine wörtlich aus einem Dokument übernommene Zeile, '
'die deine Antwort belegt>"')
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 110 (10 = klingt wie eine deutsche "
"Muttersprachlerin im Alltag). Antworte EXAKT in diesem Format:\n"
"NOTE: <Zahl 1-10>\nBEGRUENDUNG: <ein Satz>")
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()
+348
View File
@@ -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 <spec> 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 <spec>' 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 <spec.json>"; 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
@@ -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"}
]
}
@@ -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*$"}
]
}
@@ -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: <Anzahl der Datenzeilen>`, Zeile 2 `Maximum: <höchste Temperatur> °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"}
]
}
@@ -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"}
]
}
@@ -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
}
@@ -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)
@@ -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
}
@@ -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)
@@ -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
}
@@ -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)
@@ -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
}
@@ -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)
@@ -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
}
@@ -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)
@@ -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
}
@@ -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)
@@ -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 23 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 23 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 12 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 12 Sätzen, Du-Form — ohne aufdringlich zu sein."
}
]
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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.
@@ -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.
@@ -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.
@@ -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}")
@@ -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"]]
}
+419
View File
@@ -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: <exakte id aus der Liste>" / "ROLLE: <hermes|coder|heavy|vision|scout|embed>" / "BELEG: <die HF-Listenzeile wörtlich>" / "WARUM: <ein Satz mit der erwarteten Verbesserung>". Danach optional: "HINWEISE:" gefolgt von maximal 2 Spiegelstrichen mit je einem Satz + wörtlichem Beleg-Zitat aus dem Material.'
EVID="$(cat <<EOF
LIVE-PULS (JSON): $PULS_BLOCK
ENGINE-A/B: $ENGINE_BLOCK
WATCH-LISTE:
$(cat "$TMPD/watch.txt" 2>/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}"
+31
View File
@@ -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.
+29
View File
@@ -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."
}
]
}