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:
+7
-1
@@ -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.
|
# 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/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh"
|
||||||
cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.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
|
# Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen
|
||||||
# (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt).
|
# (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"
|
[ -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
|
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
|
||||||
# --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing).
|
# --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"
|
[ -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.
|
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
|
||||||
mkdir -p "$HOME/.hermes/skills/wartung"
|
mkdir -p "$HOME/.hermes/skills/wartung"
|
||||||
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
|
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
|
||||||
|
|||||||
@@ -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 1–10 (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()
|
||||||
@@ -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 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"]]
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
manuell geht's jederzeit über den Wartungs-Drawer)
|
||||||
- **Mo 05:15** Release-Radar (Hermes-Neuerungen) · **Monatlich 1., 09:00** Monats-Review
|
- **Mo 05:15** Release-Radar (Hermes-Neuerungen) · **Monatlich 1., 09:00** Monats-Review
|
||||||
(Evolution-Radar + Selbstkritik) auf Telegram
|
(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?
|
## Pinnwand: eine Ebene ist „GEPINNT" — was heißt das?
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| 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) |
|
| 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 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:00 | QNAP-Volume-Snapshot (7 behalten) |
|
||||||
| 07:15 | self-smoke (Gateway/Tools/Voice/:9119; Alarm nur bei Rot) |
|
| 07:15 | self-smoke (Gateway/Tools/Voice/:9119; Alarm nur bei Rot) |
|
||||||
| 08:00 | Daily-Briefing |
|
| 08:00 | Daily-Briefing |
|
||||||
|
|||||||
Reference in New Issue
Block a user