#!/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).
    <think>-Blöcke werden entfernt: rohe :8080-Aufrufe an Denk-Modelle (Qwen3.6)
    liefern den Grübel-Text im content mit — bewertet wird NUR die Antwort
    (Baseline-Befund 17.07.: Richter benotete sonst Denk-Monologe)."""
    if not msg:
        return ""
    text = (msg.get("content") or msg.get("reasoning_content") or "")
    text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
    # abgeschnittenes Denken ohne Schluss-Tag: ehrlich als leer werten
    text = re.sub(r"<think>.*$", "", text, flags=re.DOTALL)
    return text.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=2800)
        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]
            eintrag["antwort_auszug"] = antwort[:300]
        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=2400)
        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'})"
        if not eintrag["pass"]:
            eintrag["antwort_auszug"] = antwort[:300]
        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()
