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:
@@ -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"]]
|
||||
}
|
||||
Reference in New Issue
Block a user