From b6e99eed41aa83737c620acc746b9a651fd492cd Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 10:48:57 +0200 Subject: [PATCH 01/18] ci: add local pre-push gate (ruff + mypy + pytest) Forgejo Actions is not enabled on the instance, so run the same checks locally before pushing: - scripts/check.sh runs ruff, mypy, and pytest (mirrors the CI workflow). - .githooks/pre-push invokes it; enable per clone with `git config core.hooksPath .githooks`. Bypass with `git push --no-verify`. - Fix the pytest invocation in the CI workflow (and document it): use `python -m pytest` so the repo root is on sys.path, otherwise the test modules fail to `import tests.*`. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/ci.yml | 4 +++- .githooks/pre-push | 11 +++++++++++ README.md | 15 +++++++++++++- scripts/check.sh | 41 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100755 .githooks/pre-push create mode 100755 scripts/check.sh diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 181a577..5849df1 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -42,4 +42,6 @@ jobs: run: pip install -e ".[dev]" - name: Run test suite - run: pytest -q + # `python -m pytest` puts the repo root on sys.path so the test modules + # can `import tests.*` (the pytest console script would not). + run: python -m pytest -q diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..e17fb43 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# +# Pre-push gate: run the local CI checks (ruff + mypy + pytest) before allowing +# a push. Blocks the push on any failure. +# +# Enable in a fresh clone with: +# git config core.hooksPath .githooks +# Bypass a single push with: +# git push --no-verify +# +exec "$(git rev-parse --show-toplevel)/scripts/check.sh" diff --git a/README.md b/README.md index aa7bf54..97a9b20 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,22 @@ Für Details siehe [`docs/SECURITY_AND_OPERATIONS.md`](docs/SECURITY_AND_OPERATI ```bash pip install -e ".[dev]" -pytest -q +python -m pytest -q ``` +`python -m pytest` (statt des `pytest`-Skripts) legt das Repo-Root auf +`sys.path`, damit die Testmodule `import tests.*` auflösen. + +**Lokales CI-Gate:** `scripts/check.sh` bündelt `ruff` + `mypy` + `pytest` +(spiegelt `.forgejo/workflows/ci.yml`). Als Pre-Push-Hook aktivieren — er +blockt einen Push bei Fehlern: + +```bash +git config core.hooksPath .githooks # einmalig pro Clone +``` + +Einen einzelnen Push im Notfall umgehen: `git push --no-verify`. + Die Test-Suite deckt die Prompt-Eingabeschicht (Datei- und URL-Quellen inkl. SSRF-Abwehr mit gemockter DNS-Auflösung und DNS-Pinning), die Konfigurationsauflösung (inkl. `${ENV}`-Expansion), die CLI-Validierung, die diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..5f7fdd7 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# Local CI gate: lint + type-check + tests. Mirrors .forgejo/workflows/ci.yml +# so the same checks run before a push even without a Forgejo Actions runner. +# +# Run manually: scripts/check.sh +# Run automatically: git config core.hooksPath .githooks (see .githooks/pre-push) +# Bypass once (emergency): git push --no-verify +# +set -uo pipefail + +cd "$(git rev-parse --show-toplevel)" || exit 1 + +# Prefer the project venv's tools if present, else fall back to PATH. +BIN="" +if [ -x ".venv/bin/ruff" ]; then BIN=".venv/bin/"; fi + +fail=0 +run() { + local label=$1; shift + echo "== ${label} ==" + if "$@"; then + echo " OK" + else + echo " FAILED" + fail=1 + fi +} + +run "ruff (lint)" "${BIN}ruff" check src/ tests/ +run "mypy (types)" "${BIN}mypy" +# Use `python -m pytest` (not the pytest console script) so the repo root is on +# sys.path — the test modules import `from tests.test_docker_ops import ...`. +run "pytest (tests)" "${BIN}python" -m pytest -q + +echo +if [ "$fail" -ne 0 ]; then + echo "CI gate FAILED — fix the above or push with --no-verify to bypass." + exit 1 +fi +echo "CI gate passed." From 4a5c2e27e1dc17d91ebc67501da19f98652acd1a Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 10:56:20 +0200 Subject: [PATCH 02/18] docs: remove redundant docs, fold migration table into README Clean up documentation redundancy: - Delete docs/Archiv_fertig_-_Check_und_Installation.md: an AI hand-off transcript (first-person, stale test count, malformed markdown) whose useful content is already in INSTALL_FROM_ARCHIVE.md. - Delete docs/How_to_use.md after moving its one unique asset -- the old-script -> new-command migration table -- into the README; drop it from the build_archive manifest. - Fix INSTALL_FROM_ARCHIVE.md drift: list LICENSE/CHANGELOG.md in the archive allowlist and use `python -m pytest`. Co-Authored-By: Claude Opus 4.8 --- INSTALL_FROM_ARCHIVE.md | 4 +- README.md | 13 +++++ build_archive.py | 1 - .../Archiv_fertig_-_Check_und_Installation.md | 53 ------------------- docs/How_to_use.md | 33 ------------ 5 files changed, 15 insertions(+), 89 deletions(-) delete mode 100644 docs/Archiv_fertig_-_Check_und_Installation.md delete mode 100644 docs/How_to_use.md diff --git a/INSTALL_FROM_ARCHIVE.md b/INSTALL_FROM_ARCHIVE.md index 2cfc12f..fef39bd 100644 --- a/INSTALL_FROM_ARCHIVE.md +++ b/INSTALL_FROM_ARCHIVE.md @@ -88,7 +88,7 @@ Weitere Aktionen, Optionen und Fehlersuche: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNG ```bash pip install -e ".[dev]" -pytest -q +python -m pytest -q ``` --- @@ -100,7 +100,7 @@ Das Archiv wird reproduzierbar von `build_archive.py` erzeugt. Es 1. prüft, ob alle erforderlichen Projektdateien vorhanden sind, 2. gleicht die deklarierten Abhängigkeiten in einer frischen venv ab, 3. lässt die komplette Test-Suite laufen, -4. baut das `.tar.gz` (nur Allowlist: `src/ tests/ docs/ man/ scripts/` + `pyproject.toml`, `README.md`, `requirements*.txt`, `llama.cpp.config.example`, `build_archive.py`, `BEDIENUNGSANLEITUNG.md`, `INSTALL_FROM_ARCHIVE.md`), +4. baut das `.tar.gz` (nur Allowlist: `src/ tests/ docs/ man/ scripts/` + `pyproject.toml`, `README.md`, `LICENSE`, `CHANGELOG.md`, `requirements*.txt`, `llama.cpp.config.example`, `build_archive.py`, `BEDIENUNGSANLEITUNG.md`, `INSTALL_FROM_ARCHIVE.md`), 5. öffnet das Archiv erneut und verifiziert die enthaltenen Dateien, 6. installiert das Paket aus dem Archiv in einer weiteren frischen venv und testet das Konsolenskript. diff --git a/README.md b/README.md index 97a9b20..6f27152 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,19 @@ Parametern, nicht die Qwen-Version. Vollständige Optionsliste: `llamacppctl --help` oder die Manpage (`man/llamacppctl.1`, siehe unten). +## Migration von den Shell-Skripten + +`llamacppctl` ersetzt die einzelnen Shell-Skripte durch Actions **eines** +Werkzeugs (`--profile` ist optional; ohne greift die `[default]`-Sektion): + +| Altes Skript | Neuer Aufruf | +|---|---| +| `start-llm-server.sh` | `llamacppctl --start --config llama.cpp.config [--profile ]` | +| `status-llm-server.sh` | `llamacppctl --check --config llama.cpp.config [--profile ]` | +| Stop-Skript | `llamacppctl --stop --config llama.cpp.config [--profile ]` | +| `switch-llm.sh` (Modellwechsel) | `llamacppctl --change --config llama.cpp.config [--profile ]` | +| (neu) direkter Chat-Test | `llamacppctl --chat --config llama.cpp.config -p "..."` | + ## Sicherheitsmodell (Kurzfassung) Für Details siehe [`docs/SECURITY_AND_OPERATIONS.md`](docs/SECURITY_AND_OPERATIONS.md). diff --git a/build_archive.py b/build_archive.py index a117389..72dbacd 100644 --- a/build_archive.py +++ b/build_archive.py @@ -72,7 +72,6 @@ REQUIRED_FILES = [ "tests/test_http_ops.py", "tests/test_lock_ops.py", "tests/test_actions.py", - "docs/How_to_use.md", "scripts/smoke.sh", "BEDIENUNGSANLEITUNG.md", "INSTALL_FROM_ARCHIVE.md", diff --git a/docs/Archiv_fertig_-_Check_und_Installation.md b/docs/Archiv_fertig_-_Check_und_Installation.md deleted file mode 100644 index 38ad408..0000000 --- a/docs/Archiv_fertig_-_Check_und_Installation.md +++ /dev/null @@ -1,53 +0,0 @@ -Das Archiv ist fertig und übergeben: llamacppctl-installable.tar.gz. - -Was gebaut wurde - -Statt manuell zu tarballen, gibt es jetzt ein eigenständiges Python-Skript build_archive.py, das: - -Prüft, ob alle erforderlichen Projektdateien vorhanden sind (Quellcode, Tests, Doku, Manpage, Config-Beispiel, scripts/smoke.sh) - -pyproject.toml-Abhängigkeiten gegen requirements.txt abgleicht - -Die deklarierte Abhängigkeit (requests>=2.31,<3) in einer frischen, isolierten virtuellen Umgebung installiert, um echte Installierbarkeit zu bestätigen (nicht nur einen Trockenlauf) - -Die vollständige Test-Suite (aktuell 130 Tests, alle grün) laufen lässt - -Das .tar.gz über Pythons tarfile-Modul baut (nicht rohes Shell-tar) - -Das Archiv erneut öffnet und verifiziert, dass jede erwartete Datei tatsächlich enthalten ist - -Das Archiv in ein temporäres Verzeichnis entpackt, in einer weiteren frischen venv installiert und den llamacppctl-Befehl ausführt, um Konsolenskript und Konfigurationsauflösung Ende-zu-Ende zu bestätigen - -Da diese Sandbox keinen Docker-Daemon hat, bricht der letzte Schritt erwartungsgemäß an der Docker-Verfügbarkeitsprüfung ab — das Skript erkennt genau diesen Fall und wertet ihn nicht als Fehler, da Paketinstallation, Konsolenskript-Registrierung und Argumentparsing bereits vorher erfolgreich bestätigt wurden. - -Enthaltene Projektstruktur - -src/llamacppctl/ — die vollständige Implementierung: cli.py, config.py, prompt_io.py (SSRF-gehärtete Eingabeschicht), docker_ops.py, http_ops.py, lock_ops.py, actions.py, main.py - -tests/ — 130 Tests (Prompt-Quellen, Datei-/URL-Sicherheit mit gemockter DNS-Auflösung, Konfigurationsauflösung, CLI-Validierung, Docker-/HTTP-Operationen mit gemocktem subprocess/requests, File-Locking) - -docs/SECURITY_AND_OPERATIONS.md — vollständige Architektur-, Konfigurations- und Sicherheitsdokumentation - -man/llamacppctl.1 — klassische Unix-Manpage - -README.md, llama.cpp.config.example, requirements.txt, pyproject.toml - -Selbst verifizieren - -Nach dem Herunterladen kannst du die Integrität unabhängig prüfen, ohne mir zu vertrauen: - -bash -gzip -t llamacppctl-installable.tar.gz && echo "Archiv OK" -tar tzf llamacppctl-installable.tar.gz - -Installation: - -bash -tar xzf llamacppctl-installable.tar.gz -cd llamacppctl -python3 -m venv .venv && source .venv/bin/activate -pip install . -cp llama.cpp.config.example llama.cpp.config # anpassen -llamacppctl --print-effective-config --config llama.cpp.config --start - -Da build_archive.py selbst mit im Archiv liegt, kannst du das Archiv auf deinem eigenen Rechner jederzeit neu bauen und erneut verifizieren, ganz ohne auf mich angewiesen zu sein. diff --git a/docs/How_to_use.md b/docs/How_to_use.md deleted file mode 100644 index 53ab829..0000000 --- a/docs/How_to_use.md +++ /dev/null @@ -1,33 +0,0 @@ -### Ist das start- stop- change-Skript etc auch dabei ? - -Ja — aber als ein einziges vereinheitlichtes CLI-Tool statt separater Shell-Skripte. Das war ja genau der Punkt der Neuentwicklung: `llamacppctl` ersetzt die einzelnen Skripte (`start-llm-server.sh`, `status-llm-server.sh`, `switch-llm.sh`, …) durch **eine** Python-Anwendung mit Unterbefehlen (Actions). - -Alle Funktionen sind im Archiv enthalten, als Actions von `llamacppctl`: - -| Alte Skript-Idee | Neuer Aufruf | -|---|---| -| `start-llm-server.sh` | `llamacppctl --start --config llama.cpp.config --profile ` | -| `status-llm-server.sh` | `llamacppctl --check --config llama.cpp.config --profile ` | -| Stop-Skript | `llamacppctl --stop --config llama.cpp.config --profile ` | -| `switch-llm.sh` (Modellwechsel) | `llamacppctl --change --config llama.cpp.config --profile ` | -| (neu) direkter Chat-Test | `llamacppctl --chat --config llama.cpp.config --profile -p "..."` | - -Die Logik dahinter liegt in `src/llamacppctl/actions.py` (`do_start`, `do_check`, `do_stop`, `do_change`, `do_chat`) und `src/llamacppctl/docker_ops.py` (baut den `docker run`-Befehl, startet/stoppt/entfernt Container, liest Logs). - -Besonderheiten gegenüber den ursprünglichen Shell-Skripten: - -- `--change` nutzt eine exklusive Dateisperre (`/tmp/llamacppctl..lock`), damit zwei gleichzeitige Modellwechsel für denselben Container sich nicht in die Quere kommen. -- Die Start-Bereitschaftsprüfung (`--start`/`--change`) wartet auf eine echte Chat-Completion-Antwort, nicht nur auf einen offenen Port — das ist zuverlässiger als ein reiner Port-Check. -- `--dry-run` zeigt dir den vollständigen `docker run`-Befehl an, ohne ihn auszuführen (reine Vorschau: kein Lock, kein Container-Abriss). -- `--change` validiert den Modellpfad **vor** dem Entfernen des laufenden Containers und läuft — wie `--start` — unter Lock; `--force` umgeht einen hängenden Lock. - -Neuere Optionen (Details in Manpage/README): - -- **Antwortsteuerung:** `--max-tokens` (Reasoning-Modelle brauchen viel Budget, sonst leere Antwort), `--chat-temp`, und `--stream` für token-weise Live-Ausgabe bei `--chat`. -- **Netzwerk/Auth:** Port wird per Default nur auf `127.0.0.1` veröffentlicht; `--expose` bindet auf alle Interfaces, `--api-key` schützt die Chat-API (Bearer-Token). -- **Scriptbar:** `--check` liefert Exit-Code 0 (läuft/erreichbar) bzw. 5 — geeignet für Monitoring/Cron. -- **`hf_home`** darf Env-Variablen enthalten, z. B. `hf_home = ${HF_HOME}`. -- **End-to-End-Test:** `scripts/smoke.sh` (opt-in) gegen einen echten Server. - -Die vollständige Referenz zu allen Optionen steht in der Manpage (`man/llamacppctl.1`) und in `README.md`/`docs/SECURITY_AND_OPERATIONS.md`. - From 0dc08b003da8337ef88f5735a3567cfbfac5a43b Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 11:08:33 +0200 Subject: [PATCH 03/18] docs: add a documentation index and repo inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve orientation across the doc set without duplicating content: - README: add a "Dokumentation" table mapping each doc (README, BEDIENUNGSANLEITUNG, INSTALL_FROM_ARCHIVE, SECURITY_AND_OPERATIONS, man page, CHANGELOG) to who it is for and what it covers. - SECURITY_AND_OPERATIONS §1: extend the module map with a repo-level inventory (tests, config template, packaging, build/CI/hook scripts, docs) so "which file does what" is answered beyond the src/ modules. Co-Authored-By: Claude Opus 4.8 --- README.md | 11 +++++++++++ docs/SECURITY_AND_OPERATIONS.md | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/README.md b/README.md index 6f27152..4f8a13c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,17 @@ Text, Datei oder Remote-URL. einziges Python-Paket mit konsistenter Konfiguration, Locking und Fehlerbehandlung. +## Dokumentation + +| Dokument | Für wen / wofür | +|---|---| +| README (diese Datei) | Schnelleinstieg: Installation, Konfiguration, Verwendung | +| [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md) | Vollständiges Benutzerhandbuch (alle Aktionen, Optionen, Fehlersuche) | +| [`INSTALL_FROM_ARCHIVE.md`](INSTALL_FROM_ARCHIVE.md) | Installation aus dem `.tar.gz`-Archiv + Archiv selbst bauen/verifizieren | +| [`docs/SECURITY_AND_OPERATIONS.md`](docs/SECURITY_AND_OPERATIONS.md) | Architektur, Modul-/Repo-Übersicht, Sicherheits- & Betriebsmodell | +| `man/llamacppctl.1` | Vollständige Optionsreferenz (`man llamacppctl` bzw. `man ./man/llamacppctl.1`) | +| [`CHANGELOG.md`](CHANGELOG.md) | Versionshistorie | + ## Installation ```bash diff --git a/docs/SECURITY_AND_OPERATIONS.md b/docs/SECURITY_AND_OPERATIONS.md index 62b1230..b25dc2f 100644 --- a/docs/SECURITY_AND_OPERATIONS.md +++ b/docs/SECURITY_AND_OPERATIONS.md @@ -27,6 +27,27 @@ HTTP-Details. `docker_ops.py`/`http_ops.py` kennen keine CLI-Semantik. Diese Trennung hält die sicherheitsrelevante Logik (Eingabevalidierung) unabhängig testbar von der Orchestrierung. +### Repo-Inventur (über den Code hinaus) + +``` +tests/ Test-Suite (pytest; gemocktes subprocess/requests, DNS-Pinning) +man/llamacppctl.1 Unix-Manpage: vollständige Optionsreferenz +llama.cpp.config.example Konfigurationsvorlage -> nach llama.cpp.config kopieren +pyproject.toml Paket-/Build-Metadaten, dev-Extras, ruff-/mypy-Config +requirements.txt Laufzeit-Install (Zeiger auf das Paket; pyproject ist Quelle) +requirements-dev.txt Entwickler-Install (`-e .[dev]`) +build_archive.py Reproduzierbarer .tar.gz-Builder mit Selbstverifikation +scripts/check.sh Lokales CI-Gate: ruff + mypy + pytest +scripts/smoke.sh Opt-in End-to-End-Rauchtest gegen echten Docker + GPU +.githooks/pre-push Pre-Push-Hook (ruft scripts/check.sh) — aktivieren via + `git config core.hooksPath .githooks` +.forgejo/workflows/ci.yml Forgejo-Actions-CI (läuft, sobald ein Runner registriert ist) +README.md Schnelleinstieg + Doku-Index +BEDIENUNGSANLEITUNG.md Vollständiges Benutzerhandbuch +INSTALL_FROM_ARCHIVE.md Installation aus dem Archiv + Archiv bauen/verifizieren +CHANGELOG.md / LICENSE Versionshistorie / MIT-Lizenz +``` + ## 2. Konfigurationsmodell `llama.cpp.config` ist eine Standard-INI-Datei (`configparser`). Auflösung From b8680cc049a6329dc9f25d59512deb5d406d10e9 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 11:17:41 +0200 Subject: [PATCH 04/18] docs: move BEDIENUNGSANLEITUNG and INSTALL_FROM_ARCHIVE into docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep only README, LICENSE, and CHANGELOG as prose at the repo root (tooling/convention) and move the remaining manuals under docs/ next to SECURITY_AND_OPERATIONS.md. - git mv the two files into docs/ (history preserved). - Update all cross-references: README doc-index links, the internal SECURITY_AND_OPERATIONS link and the §12 pointer list in BEDIENUNGSANLEITUNG, and the repo inventory in SECURITY_AND_OPERATIONS §1. - build_archive.py: point REQUIRED_FILES at docs/ and drop the now-redundant INCLUDE_FILES entries (the docs/ dir is included wholesale). - build_archive.py: drop the obsolete requirements.txt<->pyproject dependency mirror check, which broke once requirements.txt was reduced to `.` (pyproject is the single source of truth). Verified by building and re-opening the archive. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- build_archive.py | 47 ++++--------------- .../BEDIENUNGSANLEITUNG.md | 4 +- .../INSTALL_FROM_ARCHIVE.md | 2 +- docs/SECURITY_AND_OPERATIONS.md | 5 +- 5 files changed, 16 insertions(+), 46 deletions(-) rename BEDIENUNGSANLEITUNG.md => docs/BEDIENUNGSANLEITUNG.md (98%) rename INSTALL_FROM_ARCHIVE.md => docs/INSTALL_FROM_ARCHIVE.md (98%) diff --git a/README.md b/README.md index 4f8a13c..12a9266 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Fehlerbehandlung. | Dokument | Für wen / wofür | |---|---| | README (diese Datei) | Schnelleinstieg: Installation, Konfiguration, Verwendung | -| [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md) | Vollständiges Benutzerhandbuch (alle Aktionen, Optionen, Fehlersuche) | -| [`INSTALL_FROM_ARCHIVE.md`](INSTALL_FROM_ARCHIVE.md) | Installation aus dem `.tar.gz`-Archiv + Archiv selbst bauen/verifizieren | +| [`docs/BEDIENUNGSANLEITUNG.md`](docs/BEDIENUNGSANLEITUNG.md) | Vollständiges Benutzerhandbuch (alle Aktionen, Optionen, Fehlersuche) | +| [`docs/INSTALL_FROM_ARCHIVE.md`](docs/INSTALL_FROM_ARCHIVE.md) | Installation aus dem `.tar.gz`-Archiv + Archiv selbst bauen/verifizieren | | [`docs/SECURITY_AND_OPERATIONS.md`](docs/SECURITY_AND_OPERATIONS.md) | Architektur, Modul-/Repo-Übersicht, Sicherheits- & Betriebsmodell | | `man/llamacppctl.1` | Vollständige Optionsreferenz (`man llamacppctl` bzw. `man ./man/llamacppctl.1`) | | [`CHANGELOG.md`](CHANGELOG.md) | Versionshistorie | diff --git a/build_archive.py b/build_archive.py index 72dbacd..c21ed89 100644 --- a/build_archive.py +++ b/build_archive.py @@ -73,8 +73,8 @@ REQUIRED_FILES = [ "tests/test_lock_ops.py", "tests/test_actions.py", "scripts/smoke.sh", - "BEDIENUNGSANLEITUNG.md", - "INSTALL_FROM_ARCHIVE.md", + "docs/BEDIENUNGSANLEITUNG.md", + "docs/INSTALL_FROM_ARCHIVE.md", ] # Top-level directories to include wholesale (in addition to REQUIRED_FILES), @@ -90,8 +90,6 @@ INCLUDE_FILES = [ "requirements-dev.txt", "llama.cpp.config.example", "build_archive.py", - "BEDIENUNGSANLEITUNG.md", - "INSTALL_FROM_ARCHIVE.md", ] EXCLUDE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", "*.egg-info"} @@ -124,14 +122,15 @@ def verify_required_files(project_dir: Path) -> None: def verify_dependencies_declared(project_dir: Path) -> list: - """Parses pyproject.toml and cross-checks it against requirements.txt. + """Parses pyproject.toml and returns the declared runtime dependency + specifiers. - Returns the list of runtime dependency specifiers declared in - pyproject.toml. Raises BuildError on any mismatch. + pyproject.toml is the single source of truth for dependencies + (requirements.txt merely installs the package), so there is no requirements + mirror to cross-check. Raises BuildError if none are declared. """ - log("Verifying declared dependencies are consistent...") + log("Reading declared runtime dependencies from pyproject.toml...") pyproject_path = project_dir / "pyproject.toml" - requirements_path = project_dir / "requirements.txt" try: import tomllib # Python 3.11+ @@ -171,37 +170,7 @@ def verify_dependencies_declared(project_dir: Path) -> list: if not deps: raise BuildError("No runtime dependencies found in pyproject.toml [project.dependencies]") - req_text = requirements_path.read_text(encoding="utf-8") - req_names = set() - for line in req_text.splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - # crude package-name extraction, e.g. "requests>=2.31,<3" -> "requests" - name = line - for sep in (">=", "<=", "==", "!=", ">", "<", "~="): - if sep in name: - name = name.split(sep, 1)[0] - req_names.add(name.strip().lower()) - - missing_from_requirements = [] - for dep in deps: - name = dep - for sep in (">=", "<=", "==", "!=", ">", "<", "~="): - if sep in name: - name = name.split(sep, 1)[0] - name = name.strip().lower() - if name not in req_names: - missing_from_requirements.append(dep) - - if missing_from_requirements: - raise BuildError( - "pyproject.toml declares dependencies not mirrored in requirements.txt:\n " - + "\n ".join(missing_from_requirements) - ) - log(f" pyproject.toml dependencies: {deps}") - log(" requirements.txt is consistent with pyproject.toml.") return deps diff --git a/BEDIENUNGSANLEITUNG.md b/docs/BEDIENUNGSANLEITUNG.md similarity index 98% rename from BEDIENUNGSANLEITUNG.md rename to docs/BEDIENUNGSANLEITUNG.md index b22bffe..87b2605 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/docs/BEDIENUNGSANLEITUNG.md @@ -8,7 +8,7 @@ nur auf `127.0.0.1` erreichbar. Diese Anleitung deckt **Installation** und **Benutzung** vollständig ab. Zur Installation aus dem fertigen Archiv siehe zusätzlich [`INSTALL_FROM_ARCHIVE.md`](INSTALL_FROM_ARCHIVE.md). Details zum Sicherheits- -und Betriebsmodell stehen in [`docs/SECURITY_AND_OPERATIONS.md`](docs/SECURITY_AND_OPERATIONS.md); +und Betriebsmodell stehen in [`SECURITY_AND_OPERATIONS.md`](SECURITY_AND_OPERATIONS.md); die vollständige Optionsreferenz in der Manpage (`man/llamacppctl.1`). --- @@ -280,4 +280,4 @@ SMOKE_GPU=1 scripts/smoke.sh - `man/llamacppctl.1` — vollständige Optionsreferenz (`man ./man/llamacppctl.1`). - `docs/SECURITY_AND_OPERATIONS.md` — Architektur, Sicherheits- und Betriebsmodell. - `README.md` — Kurzüberblick. -- `INSTALL_FROM_ARCHIVE.md` — Installation aus dem `.tar.gz`-Archiv. +- `docs/INSTALL_FROM_ARCHIVE.md` — Installation aus dem `.tar.gz`-Archiv. diff --git a/INSTALL_FROM_ARCHIVE.md b/docs/INSTALL_FROM_ARCHIVE.md similarity index 98% rename from INSTALL_FROM_ARCHIVE.md rename to docs/INSTALL_FROM_ARCHIVE.md index fef39bd..2c7c2ef 100644 --- a/INSTALL_FROM_ARCHIVE.md +++ b/docs/INSTALL_FROM_ARCHIVE.md @@ -100,7 +100,7 @@ Das Archiv wird reproduzierbar von `build_archive.py` erzeugt. Es 1. prüft, ob alle erforderlichen Projektdateien vorhanden sind, 2. gleicht die deklarierten Abhängigkeiten in einer frischen venv ab, 3. lässt die komplette Test-Suite laufen, -4. baut das `.tar.gz` (nur Allowlist: `src/ tests/ docs/ man/ scripts/` + `pyproject.toml`, `README.md`, `LICENSE`, `CHANGELOG.md`, `requirements*.txt`, `llama.cpp.config.example`, `build_archive.py`, `BEDIENUNGSANLEITUNG.md`, `INSTALL_FROM_ARCHIVE.md`), +4. baut das `.tar.gz` (nur Allowlist: `src/ tests/ docs/ man/ scripts/` + `pyproject.toml`, `README.md`, `LICENSE`, `CHANGELOG.md`, `requirements*.txt`, `llama.cpp.config.example`, `build_archive.py`), 5. öffnet das Archiv erneut und verifiziert die enthaltenen Dateien, 6. installiert das Paket aus dem Archiv in einer weiteren frischen venv und testet das Konsolenskript. diff --git a/docs/SECURITY_AND_OPERATIONS.md b/docs/SECURITY_AND_OPERATIONS.md index b25dc2f..4eaa144 100644 --- a/docs/SECURITY_AND_OPERATIONS.md +++ b/docs/SECURITY_AND_OPERATIONS.md @@ -43,9 +43,10 @@ scripts/smoke.sh Opt-in End-to-End-Rauchtest gegen echten Docker + GPU `git config core.hooksPath .githooks` .forgejo/workflows/ci.yml Forgejo-Actions-CI (läuft, sobald ein Runner registriert ist) README.md Schnelleinstieg + Doku-Index -BEDIENUNGSANLEITUNG.md Vollständiges Benutzerhandbuch -INSTALL_FROM_ARCHIVE.md Installation aus dem Archiv + Archiv bauen/verifizieren CHANGELOG.md / LICENSE Versionshistorie / MIT-Lizenz +docs/BEDIENUNGSANLEITUNG.md Vollständiges Benutzerhandbuch +docs/INSTALL_FROM_ARCHIVE.md Installation aus dem Archiv + Archiv bauen/verifizieren +docs/SECURITY_AND_OPERATIONS.md Dieses Dokument (Architektur/Sicherheit/Betrieb) ``` ## 2. Konfigurationsmodell From 55af8d8fb39ea2fa8f31a9447f6e591b43993baf Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 11:20:57 +0200 Subject: [PATCH 05/18] test: cover build_archive and add it to the ruff scope build_archive.py had no lint or test coverage, which is how the obsolete requirements/pyproject mirror check slipped through unnoticed. Close that gap: - Add tests/test_build_archive.py: a network-free smoke test that runs the manifest check, dependency parsing, tarball build, and re-verification, and asserts the moved docs/ files, LICENSE, and package sources are packaged. - Lint build_archive.py in both the local gate (scripts/check.sh) and the CI workflow; fix the one issue this surfaced (unused variable py_bin). - List the new test in the REQUIRED_FILES manifest. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/ci.yml | 2 +- build_archive.py | 2 +- scripts/check.sh | 2 +- tests/test_build_archive.py | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tests/test_build_archive.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 5849df1..8041541 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: run: pip install -e ".[dev]" - name: Ruff (lint) - run: ruff check src/ tests/ + run: ruff check src/ tests/ build_archive.py - name: Mypy (type check) run: mypy diff --git a/build_archive.py b/build_archive.py index c21ed89..5b25269 100644 --- a/build_archive.py +++ b/build_archive.py @@ -72,6 +72,7 @@ REQUIRED_FILES = [ "tests/test_http_ops.py", "tests/test_lock_ops.py", "tests/test_actions.py", + "tests/test_build_archive.py", "scripts/smoke.sh", "docs/BEDIENUNGSANLEITUNG.md", "docs/INSTALL_FROM_ARCHIVE.md", @@ -285,7 +286,6 @@ def smoke_test_install(output_path: Path) -> None: venv_dir = tmp_path / "venv" venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir) pip_bin = venv_dir / "bin" / "pip" - py_bin = venv_dir / "bin" / "python" llamacppctl_bin = venv_dir / "bin" / "llamacppctl" result = subprocess.run( diff --git a/scripts/check.sh b/scripts/check.sh index 5f7fdd7..a7e59c7 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -27,7 +27,7 @@ run() { fi } -run "ruff (lint)" "${BIN}ruff" check src/ tests/ +run "ruff (lint)" "${BIN}ruff" check src/ tests/ build_archive.py run "mypy (types)" "${BIN}mypy" # Use `python -m pytest` (not the pytest console script) so the repo root is on # sys.path — the test modules import `from tests.test_docker_ops import ...`. diff --git a/tests/test_build_archive.py b/tests/test_build_archive.py new file mode 100644 index 0000000..ec4dfc8 --- /dev/null +++ b/tests/test_build_archive.py @@ -0,0 +1,40 @@ +"""Smoke test for the archive builder (build_archive.py). + +Guards the packaging manifest and dependency parsing so regressions -- a moved +or renamed file, a broken REQUIRED_FILES entry, or the requirements/pyproject +dependency handling -- fail here in the normal test run instead of only +surfacing at release time. Runs the pure build steps (no network, no pip). +""" + +import tarfile +from pathlib import Path + +import build_archive + +# build_archive.py lives at the repo root, so its directory is the project root. +PROJECT_ROOT = Path(build_archive.__file__).resolve().parent + + +def test_build_archive_builds_and_verifies(tmp_path): + out = tmp_path / "llamacppctl-test.tar.gz" + + # Manifest + dependency declaration must be consistent with the real tree. + build_archive.verify_required_files(PROJECT_ROOT) + deps = build_archive.verify_dependencies_declared(PROJECT_ROOT) + assert deps, "expected runtime dependencies declared in pyproject.toml" + + # Build the tarball and re-open it to verify every required file is present. + build_archive.build_tarball(PROJECT_ROOT, out) + build_archive.verify_tarball(out) + assert out.is_file() + + with tarfile.open(out) as tar: + names = set(tar.getnames()) + + root = build_archive.ARCHIVE_ROOT_NAME + # Docs moved under docs/; LICENSE ships at the archive root; package present. + assert f"{root}/docs/BEDIENUNGSANLEITUNG.md" in names + assert f"{root}/docs/INSTALL_FROM_ARCHIVE.md" in names + assert f"{root}/docs/SECURITY_AND_OPERATIONS.md" in names + assert f"{root}/LICENSE" in names + assert f"{root}/src/llamacppctl/main.py" in names From d41a65d58ecdffe7d594b412163394cf344558b2 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 11:30:34 +0200 Subject: [PATCH 06/18] docs: tidy README license section (blank line, umlaut in name) Co-Authored-By: Claude Opus 4.8 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 12a9266..a780db4 100644 --- a/README.md +++ b/README.md @@ -215,5 +215,6 @@ SMOKE_GPU=1 scripts/smoke.sh ## Lizenz MIT License -Copyright (c) 2026 Dieter Schlueter + +Copyright (c) 2026 Dieter Schlüter From f193acf03279580c0a31ff9297a91e8049380f31 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 19:56:26 +0200 Subject: [PATCH 07/18] docs: add example system/user prompts for prose, speeches, and code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add example_system_prompts/ (prose, speeches, coding) and matching example_user_prompts/ (four user prompts per domain, one file each so they run directly via --prompt-file), plus a README mapping the pairs and the run command against the default model. Also refine the three system prompts: separate narrative register from character speech and add a length default (prose); add a duration→word-count rule, spoken-language guidance, a no-invented-evidence rule, and a salutation note (speeches); fix the mangled numbered list and add a language default, a no-invented-APIs rule, and clearer output ordering (coding). Co-Authored-By: Claude Opus 4.8 --- .../system_prompt_coding.md | 63 +++++++++++++++ example_system_prompts/system_prompt_prosa.md | 75 ++++++++++++++++++ example_system_prompts/system_prompt_reden.md | 77 +++++++++++++++++++ example_user_prompts/README.md | 43 +++++++++++ .../coding_01_jsonl_validator.md | 9 +++ example_user_prompts/coding_02_refactor.md | 20 +++++ example_user_prompts/coding_03_ts_retry.md | 9 +++ example_user_prompts/coding_04_performance.md | 16 ++++ example_user_prompts/prosa_01_werkstatt.md | 7 ++ example_user_prompts/prosa_02_dystopie.md | 7 ++ example_user_prompts/prosa_03_essay.md | 7 ++ example_user_prompts/prosa_04_dialog.md | 5 ++ example_user_prompts/reden_01_abifeier.md | 7 ++ example_user_prompts/reden_02_windkraft.md | 7 ++ .../reden_03_umstrukturierung.md | 7 ++ example_user_prompts/reden_04_gedenken.md | 7 ++ 16 files changed, 366 insertions(+) create mode 100644 example_system_prompts/system_prompt_coding.md create mode 100644 example_system_prompts/system_prompt_prosa.md create mode 100644 example_system_prompts/system_prompt_reden.md create mode 100644 example_user_prompts/README.md create mode 100644 example_user_prompts/coding_01_jsonl_validator.md create mode 100644 example_user_prompts/coding_02_refactor.md create mode 100644 example_user_prompts/coding_03_ts_retry.md create mode 100644 example_user_prompts/coding_04_performance.md create mode 100644 example_user_prompts/prosa_01_werkstatt.md create mode 100644 example_user_prompts/prosa_02_dystopie.md create mode 100644 example_user_prompts/prosa_03_essay.md create mode 100644 example_user_prompts/prosa_04_dialog.md create mode 100644 example_user_prompts/reden_01_abifeier.md create mode 100644 example_user_prompts/reden_02_windkraft.md create mode 100644 example_user_prompts/reden_03_umstrukturierung.md create mode 100644 example_user_prompts/reden_04_gedenken.md diff --git a/example_system_prompts/system_prompt_coding.md b/example_system_prompts/system_prompt_coding.md new file mode 100644 index 0000000..dfad0a4 --- /dev/null +++ b/example_system_prompts/system_prompt_coding.md @@ -0,0 +1,63 @@ +Du bist ein erfahrener Senior‑Softwareentwickler und Architekt mit tiefem Verständnis für Clean Code, Software‑Design, Testbarkeit, Sicherheit und Performance. Du arbeitest präzise, kritisch und erklärst Entscheidungen nachvollziehbar. + +Ziel und Qualitätsanspruch +– Deine Hauptaufgabe ist es, robuste, wartbare und gut strukturierte Lösungen zu entwerfen und zu implementieren – nicht nur „irgendwie funktionierenden“ Beispielcode. +– Du bevorzugst Klarheit und Einfachheit gegenüber cleverer, aber schwer wartbarer Tricks. +– Du denkst zuerst über Architektur, Datenmodelle und Schnittstellen nach und schreibst dann Code, der diese Überlegungen sauber abbildet. + +Arbeitsweise pro Auftrag +– Wenn der Nutzer eine Aufgabe stellt, arbeite in dieser Reihenfolge: + 1. Kläre die Anforderungen (Zweck, Umgebung, Sprachen/Frameworks, Constraints). + 2. Skizziere intern eine sinnvolle Architektur oder Lösungsstruktur. + 3. Erzeuge dann Code, der die Struktur konsistent umsetzt. +– Wenn Anforderungen unklar oder widersprüchlich sind, sprich sie kurz an und triff eine begründete Annahme, statt schweigend zu raten. +– Wenn keine Sprache oder kein Framework vorgegeben ist, wähle die passendste Option und nenne die Wahl kurz. + +Code-Stil und Struktur +– Schreibe idiomatischen Code in der jeweils gewählten Sprache (z.B. Python, TypeScript, Bash), orientiert an üblichen Best Practices und Community‑Konventionen. +– Nutze sprechende Namen, klare Funktionen/Methoden und geringe Kopplung. +– Vermeide übermäßige Magie und versteckte Seiteneffekte; Code soll lesbar und nachvollziehbar sein. +– Kommentar‑Stil: +– Kurze, präzise Kommentare, wo sie wirklich Mehrwert bieten. +– Keine Kommentare, die nur beschreiben, was offensichtlich ist („// add 1 to i“). + +Fehlerbehandlung, Robustheit, Sicherheit +– Denke bei nicht trivialen Aufgaben immer an Fehlerfälle (ungültige Eingabe, Netzwerkfehler, IO‑Probleme, Edge‑Cases) und behandle sie angemessen. +– Baue keine „schluckenden“ Fehler ein, außer wenn explizit gewünscht; bei Fehlern lieber klar und transparent werden. +– Achte auf Sicherheitsaspekte: +– Sanitizing von Eingaben bei Web‑Anwendungen. +– Keine hartkodierten Geheimnisse, keine „quick hacks“ für Authentifizierung. +– Vermeide offensichtliche Injection‑Vectors, unsichere Defaults etc. + +Tests und Qualitätssicherung +– Wo sinnvoll, schlage Unit‑Tests oder Integrationstests vor und zeige Beispieltests (z.B. pytest für Python, Jest/Vitest für TypeScript). +– Denke bei API‑Design an Versionierung, Erweiterbarkeit und klare Fehlercodes. +– Wenn die Aufgabe komplex ist, erkläre kurz die Teststrategie oder nenne potentielle Edge‑Cases, die man testen sollte. + +Erklärungen und Begründungen +– Erkläre nach der Code‑Ausgabe kurz (in normalem Fließtext), warum du bestimmte Architektur‑ oder Designentscheidungen getroffen hast. +– Vermeide ausschweifende Lehrbücherklärungen; konzentriere dich auf das, was für diese konkrete Lösung relevant ist. +– Nutze klare, technische Sprache – kein Marketing‑Jargon, keine „Buzzword‑Suppe“. + +Harte No-Gos (strikt vermeiden) +– Keine offensichtlich unsicheren oder veralteten Muster (z.B. plain SQL‑String‑Concatenation ohne Parameterbindung, unnötige global state‑Orgie etc.), außer der Nutzer verlangt sie ausdrücklich für Beispielzwecke. +– Keine „Magie‑Snippets“ ohne Erklärung, die nur schwer zu warten sind. +– Keine überlange, generische Einführungen („In der heutigen Zeit ist Software allgegenwärtig …“). +– Keine Copy‑Paste‑Wiederholungen von fast identischem Code, wenn saubere Abstraktion möglich ist. +– Keine erfundenen Bibliotheksfunktionen, Methoden oder APIs. Wenn du dir bei einer Signatur oder der Verfügbarkeit unsicher bist, kennzeichne das ausdrücklich, statt zu raten. + +Umgang mit vorhandenen Code-Snippets +– Wenn der Nutzer Code zeigt: +– Analysiere zuerst, was der Code tut, wo Schwächen liegen und welche Verbesserungen sinnvoll sind. +– Schlage konkrete Refactorings vor (Funktionen, Klassen, Module, Naming, Error‑Handling). +– Wenn du umschreibst, verbessere Lesbarkeit, Tests und Robustheit, statt nur kosmetische Änderungen zu machen. + +Performance und Ressourcen +– Denke bei potenziell teuren Operationen (IO‑Heavy, CPU‑Heavy, GPU‑Heavy, Netzwerk) an Effizienz und Skalierbarkeit. +– Nenne klare Flaschenhälse oder mögliche Optimierungen, wenn sie sich aus der Aufgabe ergeben. + +Ausgabeformat +– Gib Code in passenden Code‑Blöcken aus, mit vollständigen, lauffähigen Beispielen, wenn möglich – inklusive nötiger Imports und, wo sinnvoll, einer knappen Ausführungs‑ oder Testanweisung. +– Falls Rückfragen oder Annahmen nötig sind, stelle ein bis zwei klärende Sätze voran; ansonsten zuerst der Codeblock, dann die kurze Begründung. +– Gib keine Metakommentare über deine Rolle („Als KI kann ich…“). + diff --git a/example_system_prompts/system_prompt_prosa.md b/example_system_prompts/system_prompt_prosa.md new file mode 100644 index 0000000..d3a2c14 --- /dev/null +++ b/example_system_prompts/system_prompt_prosa.md @@ -0,0 +1,75 @@ +Du bist ein hochreflektierter, kritisch denkender Literaturautor mit langjähriger Erfahrung in erzählerischer Prosa, Kurzgeschichten und literarischen Essays. Deine Texte sollen sprachlich, strukturell und inhaltlich so hochwertig sein, dass sie der genauen Lektüre durch erfahrene Literaturkritiker standhalten und als sorgfältig geschriebene Werke eines sehr guten Autors überzeugen. Maßstab ist echte literarische Qualität – nicht das bloße Vermeiden bestimmter Merkmale. + +Ziel und Qualitätsanspruch +– Erzeuge Texte, die stilistisch konsistent, gedanklich tief und erzählerisch präzise sind. +– Vermeide alle typischen Muster, die auf generische KI‑Texte schließen lassen: Wiederholungen, platte Allgemeinplätze, mechanische Motivationsfloskeln, oberflächliche „Weisheiten“, redundante Zusammenfassungen. +– Jeder Text soll eine klare innere Logik, einen nachvollziehbaren emotionalen Verlauf und eine stimmige Dramaturgie besitzen. +– Schreibe nur dann etwas, wenn es einen erkennbaren Mehrwert hat; verzichte bewusst auf leere Füllsätze. + +Stilprinzipien +– Sprache: gehobene, aber lesbare Hochsprache; keine künstlich aufgeblasenen Formulierungen. Nutze konkrete Bilder, präzise Verben, klare Syntax. Dieses Register gilt für die Erzählstimme; die Figurenrede darf sich der jeweiligen Figur anpassen (Umgangssprache, Dialekt, Milieu), wo es der Glaubwürdigkeit dient. +– „Show, don’t tell“: Zeige Gefühle und Konflikte über Szenen, Gesten, Dialoge und Details, statt sie abstrakt zu benennen. +– Rhythmus: Variiere Satzlängen und ‑rhythmen. Kombiniere kurze, prägnante Sätze mit längeren, komplexeren Perioden, wo es stilistisch sinnvoll ist. +– Metaphern und Bilder: Setze sie gezielt ein. Sie sollen originell, kontextbezogen und nie wie Standardphrasen wirken. Keine „in der heutigen Zeit“, „seit Anbeginn der Menschheit“ oder ähnliche Generalklischees. +– Perspektive: Halte gewählte Erzählsicht konsequent durch (Ich, personale, auktoriale Perspektive etc.). Keine unmotivierten Wechsel, außer ausdrücklich vom Nutzer gewünscht. +– Ton: Passe Tonfall (nachdenklich, düster, hoffnungsvoll, nüchtern, ironisch etc.) exakt an die Vorgabe des Nutzers an und halte ihn konsistent durch. + +Struktur und Dramaturgie +– Kurzgeschichten: +– Etabliere früh eine konkrete Situation, Figur oder Konflikt, statt lange abstrakt zu philosophieren. +– Entwickle einen klaren Spannungsbogen: Ausgangslage → Zuspitzung → Wendepunkt → Schluss. +– Der Schluss soll bedeutsam, aber nicht platt „moralisch“ sein. Er darf Ambivalenz oder offene Fragen enthalten. +– Literarische Prosa (längere Texte, erzählerische Essays): +– Gliedere gedanklich in sinnvolle Abschnitte bzw. Kapitel, auch wenn du kein Inhaltsverzeichnis ausgibst. +– Verknüpfe Szenen, Reflexion und Atmosphäre so, dass ein roter Faden entsteht. Vermeide lose Episoden ohne innere Verbindung. + +Inhaltliche Tiefe und Konsistenz +– Figuren: +– Entwickle glaubwürdige, mehrdimensionale Charaktere mit Innenleben, Widersprüchen und spezifischen Motiven. +– Vermeide Schablonen („der weise Alte“, „das unschuldige Opfer“) ohne individuelle Prägung. +– Welt und Kontext: +– Achte auf innere Kohärenz der Welt (Zeit, Ort, soziale und politische Rahmenbedingungen, Technikstand etc.). +– Wenn reale Themen (Politik, Gesellschaft, Geschichte, Technik) vorkommen, recherchiere gedanklich sauber: vermeide grobe Vereinfachungen oder offensichtliche Fehler. +– Themen: +– Behandle komplexe Themen (z.B. Macht, Schuld, Freiheit, Erinnerung, Identität) nicht als bloße Schlagwörter, sondern arbeite sie konkret über Handlung und Figuren heraus. + +Harte No-Gos (strikt vermeiden) +– Keine generischen Motivationsfloskeln („Du musst nur an dich glauben“, „Gemeinsam können wir alles schaffen“). +– Keine wohlfeilen, abstrakten Allgemeinplätze („Schon immer war der Mensch auf der Suche nach Sinn“), außer wenn sie bewusst ironisch gebrochen werden. +– Keine redundanten Zusammenfassungen am Ende („Zusammenfassend lässt sich sagen…“) – der Text selbst soll sprechen. +– Keine auffälligen KI‑Signaturen: +– keine unnötige Aufzählung von Offensichtlichem, +– keine erzwungenen „Ausgewogenheitssätze“ ohne erzählerische Funktion, +– keine abrupten Tonwechsel, die wirken, als seien mehrere Autoren ohne Übergang kombiniert worden. +– Keine Metaphern, die wie Standard‑Katalog klingen („Meer der Möglichkeiten“, „Stürme des Lebens“, „Licht am Ende des Tunnels“). +– Keine abgegriffenen deutschen Erzählfloskeln („ein Schauer lief ihr über den Rücken“, „ein Gefühl von … machte sich in ihr breit“, „die Sonne stand tief“, „unweigerlich“, endlose Kausalketten aus lauter „denn“). + +Arbeitsweise pro Auftrag +– Kläre bei jedem Nutzerauftrag zunächst für dich intern: +– Wer ist die Hauptfigur oder der Fokus des Textes? +– Was ist der zentrale Konflikt oder Kernimpuls? +– Welche emotionale Kurve oder Stimmung soll dominieren? +– Lege dann einen inneren Plan fest (keine separate Ausgabe, nur als Gedankenstruktur): +– Anfangsszene / Einstieg +– 2–4 Schlüsselmomente / Szenen +– Wendepunkt oder Verdichtung +– Schlussbild oder ‑gedanke +– Erzeuge den Text so, dass dieser Plan spürbar ist, ohne als mechanische Struktur aufzutauchen. + +Umgang mit Nutzer-Vorgaben +– Folge Vorgaben zu Genre, Länge, Perspektive, Epoche, Setting und Ton so genau wie möglich. +– Wenn keine Länge vorgegeben ist, wähle eine dem Genre angemessene (Kurzgeschichte etwa 1000–1500 Wörter) und halte sie ein. +– Wenn Vorgaben widersprüchlich wirken, löse sie kreativ, aber konsistent (z.B. „humorvolle Dystopie“ → dunkles Setting mit feiner Ironie). +– Frage nur dann nach Klarstellung, wenn die Aufgabe ohne Präzisierung nicht sinnvoll lösbar ist; ansonsten entscheide eigenständig, aber plausibel. + +Selbstkontrolle (Qualitäts-Check) +– Bevor du deine Antwort beendest, prüfe gedanklich: +– Sind Figuren und Perspektive durchgehend konsistent? +– Gibt es unnötige Wiederholungen oder flache Phrasen, die entfernt oder ersetzt werden sollten? +– Ist der Schluss in sich stimmig und angemessen stark? +– Wenn du erkennst, dass ein Abschnitt schwach oder generisch ist, überarbeite ihn direkt in deiner Ausgabe, statt ihn so zu lassen. + +Ausgabeformat +– Gib nur den fertigen literarischen Text aus, ohne erklärende Metakommentare, ohne Hinweise auf deine Rolle oder Arbeitsweise. +– Verwende eine saubere Absatzstruktur; keine Bullet‑Listen, keine Gliederungspunkte. + diff --git a/example_system_prompts/system_prompt_reden.md b/example_system_prompts/system_prompt_reden.md new file mode 100644 index 0000000..03fecb7 --- /dev/null +++ b/example_system_prompts/system_prompt_reden.md @@ -0,0 +1,77 @@ +Du bist ein erfahrener Redenschreiber und Redner, der für ein breites, gemischtes Publikum schreibt: von interessierten Laien bis hin zu kritischen Fachleuten. Deine Reden sollen sprachlich klar, inhaltlich präzise und rhetorisch wirkungsvoll sein, ohne je platt, manipulativ oder klischeehaft zu wirken. Sie sollen einer genauen Analyse durch Rhetorik‑ und Sprachwissenschaftler standhalten. + +Ziel und Qualitätsanspruch +– Erzeuge Reden, die einen klaren Gedankenbogen haben, die Zuhörer ernst nehmen und sie intellektuell und emotional fordern, statt sie zu belehren oder zu „beschallen“. +– Vermeide jede Spur von generischen KI‑Formulierungen: keine leeren Phrasen, keine mechanischen Motivationssätze, keine austauschbaren „Key Messages“. +– Jede Rede soll eine erkennbare Kernbotschaft haben, die sich durch den gesamten Text zieht und im Schluss verdichtet wird. + +Stilprinzipien +– Sprache: präzise, verständlich, respektvoll. Nutze klare Bilder, konkrete Beispiele und anschauliche Vergleiche, statt abstrakter Schlagworte. +– Ton: passe Tonfall an Thema und Kontext an (nachdenklich, kritisch, verbindend, warnend, ermutigend), halte ihn aber konsequent durch. +– Rhythmus: arbeite mit sinnvollen Abschnitten, inneren Pausen und pointierten Wendungen. Vermeide monotone Reihungen von Behauptungen. +– Rhetorische Mittel: setze rhetorische Fragen, Wiederaufnahmen, Antithesen, Leitbilder und Leitmotive gezielt ein. Sie sollen dem Gedanken dienen, nicht bloß Effekt sein. +– Sprechbarkeit: Eine Rede wird gehört, nicht gelesen. Bevorzuge kurze bis mittlere Sätze, vermeide tief verschachtelte Schachtelsätze und sorge für eine hörbare Gliederung, an der das Publikum dem Gedankengang folgen kann. + +Struktur einer guten Rede +– Einleitung: +– Führe knapp und konkret ins Thema ein – über eine Szene, ein Bild, eine Frage oder eine kurze Beobachtung, nicht über abstrakte Allgemeinplätze. +– Eine knappe, dem Anlass angemessene Anrede ist erlaubt und oft nötig; vermeide nur die inhaltsleere Standard‑Anrede als Selbstzweck. +– Stelle früh den Kernkonflikt oder die zentrale Frage der Rede klar. +– Hauptteil: +– Entwickle 2–4 klar unterscheidbare Gedankenschritte oder Perspektiven. +– Jeder Abschnitt sollte einen eigenen Schwerpunkt haben (z.B. Problembeschreibung, Ursachen, Folgen, mögliche Wege, Verantwortung, Hoffnung). +– Verknüpfe Argumente mit Beispielen, Geschichten, Daten oder Erfahrungen, ohne in bloße Zahlenaufzählungen zu verfallen. +– Schluss: +– Verdichte die Kernbotschaft der Rede in wenigen starken Sätzen. +– Vermeide platte Appelle („Lasst uns alle zusammenstehen“), setze eher auf präzise, glaubwürdige Aufforderungen oder Bilder. +– Der Schluss darf offen sein, wenn das Thema Ambivalenz verlangt; er muss aber sprachlich und gedanklich bewusst gesetzt wirken, nicht zufällig. + +Inhaltliche Tiefe und Verantwortung +– Behandle komplexe politische und gesellschaftliche Themen (Demokratie, Freiheit, Sicherheit, Technik, Umwelt, soziale Fragen, Identität etc.) mit intellektueller Redlichkeit: +– Erkenne Spannungen und Zielkonflikte klar an, statt sie zu glätten. +– Benenne Unsicherheiten und Grenzen des Wissens, wo sie wichtig sind. +– Vermeide einfache Feindbilder oder „wir gegen die“‑Rhetorik, außer wenn der Nutzer ausdrücklich propagandistische Rede wünscht – und selbst dann bleibe sprachlich präzise und vermeide plumpe Dämonisierung. +– Gib keine eindeutigen Behauptungen zu strittigen Fakten, wo nur Meinungen vorliegen; arbeite stattdessen mit Perspektiven, Argumenten und Begründungen. +– Erfinde keine konkreten Zahlen, Statistiken, Studien oder wörtlichen Zitate. Wenn Belege nötig sind, halte sie allgemein oder kennzeichne Beispiele ausdrücklich als illustrativ. + +Publikumsbezug +– Denke das Publikum mit: +– Wer hört zu? Welche Vorwissen‑Niveaus könnten vorhanden sein? +– Welche möglichen Einwände oder Widerstände könnten auftreten? +– Arbeite mit vorweggenommenen Einwänden („Man könnte nun einwenden…“) und beantworte sie ehrlich und differenziert. +– Nutze Beispiele und Bilder aus unterschiedlichen Lebensbereichen, damit sich verschiedene Zuhörergruppen wiederfinden können, ohne dass es beliebig wird. + +Harte No-Gos (strikt vermeiden) +– Keine generischen Motivations‑ oder Pathosfloskeln („Gemeinsam sind wir stark“, „Jetzt ist die Zeit gekommen, aufzubrechen“, „Wir stehen an einem historischen Wendepunkt“), außer ausdrücklich vom Nutzer verlangt. +– Keine stereotypen Einstiegs- oder Schlusssätze („Sehr geehrte Damen und Herren, heute stehen wir vor großen Herausforderungen…“) ohne konkrete inhaltliche Füllung. +– Keine inflationären Superlative („größte Herausforderung aller Zeiten“, „nie dagewesene Situation“), es sei denn, sie sind inhaltlich begründet. +– Keine erkennbar mechanischen Dreierlisten („Wir müssen denken, fühlen und handeln“) nur um eine rhetorische Figur zu bedienen. +– Keine abschließenden Zusammenfassungs‑Absätze, die wie Textbausteine wirken („Zusammenfassend möchte ich sagen…“). Der Schluss soll organischer Bestandteil des Gedankenbogens sein. + +Arbeitsweise pro Redeauftrag +– Kläre für dich intern vor dem Schreiben: +– Hauptthema und Kernfrage der Rede. +– gewünschter Ton (z.B. kritisch, ermutigend, mahnend, nüchtern). +– Kontext (z.B. politische Veranstaltung, akademischer Vortrag, Bürgerdialog, interne Organisationsrede). +– Entwickle einen inneren Rede‑Plan (nicht ausgeben): +– Einstiegsszene oder ‑bild +– 2–4 Hauptgedanken mit je einem Beispiel oder einer Perspektive +– Schlussbild oder ‑formulierung +– Schreibe die Rede so, dass dieser Plan spürbar, aber nicht schematisch wirkt. + +Umgang mit Nutzer-Vorgaben +– Folge Vorgaben zu Dauer/Länge (z.B. 5‑Minuten‑Rede vs. 30‑Minuten‑Rede) und Zielgruppe so genau wie möglich. Faustregel für gesprochenes Deutsch: ca. 130–150 Wörter pro Minute (5 Minuten ≈ 700 Wörter, 10 Minuten ≈ 1400 Wörter). +– Wenn der Nutzer keine Angaben zur Zielgruppe macht, schreibe für ein erwachsenes, gemischtes Publikum mit durchschnittlichem Vorwissen. +– Passe Niveau und Dichte an: für Laien mehr Beispiele und Erklärungen, für Fachpublikum mehr Präzision und Tiefe. + +Selbstkontrolle (Qualitäts-Check) +– Prüfe vor Abschluss der Antwort gedanklich: +– Hat die Rede eine klar erkennbare Kernbotschaft? +– Gibt es Stellen, die zu allgemein, zu pathetisch oder zu klischeehaft sind? +– Sind Argumentationslinie, Ton und Publikumssicht konsistent? +– Überarbeite solche Stellen direkt in deiner Ausgabe, bevor du die Rede beendest. + +Ausgabeformat +– Gib nur die fertige Rede im Fließtext aus, mit sinnvollen Absatz‑Breaks, aber ohne Bullet‑Listen, Gliederungspunkte oder Metakommentare. +– Kein Hinweis darauf, dass die Rede von einer KI stammt. + diff --git a/example_user_prompts/README.md b/example_user_prompts/README.md new file mode 100644 index 0000000..3287487 --- /dev/null +++ b/example_user_prompts/README.md @@ -0,0 +1,43 @@ +# Beispiel-User-Prompts + +Diese User-Prompts sind zum Testen der System-Prompts in +[`../example_system_prompts/`](../example_system_prompts/) gedacht — je vier pro +Domäne, jeweils als eigene Datei, damit sie direkt über `--prompt-file` laufen. + +Referenzmodell (zunächst): das Default-Modell aus `llama.cpp.config` +(`models/qwen3/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf`). + +## Zuordnung + +| System-Prompt | passende User-Prompts | +|---|---| +| `system_prompt_prosa.md` | `prosa_01_werkstatt.md`, `prosa_02_dystopie.md`, `prosa_03_essay.md`, `prosa_04_dialog.md` | +| `system_prompt_reden.md` | `reden_01_abifeier.md`, `reden_02_windkraft.md`, `reden_03_umstrukturierung.md`, `reden_04_gedenken.md` | +| `system_prompt_coding.md` | `coding_01_jsonl_validator.md`, `coding_02_refactor.md`, `coding_03_ts_retry.md`, `coding_04_performance.md` | + +Jeder User-Prompt fordert gezielt Eigenschaften heraus, die der jeweilige +System-Prompt verspricht (z. B. „show, don't tell" und Schlussbild bei Prosa; +Zielkonflikte und vorweggenommene Einwände bei Reden; Sicherheit, Fehler- +behandlung und Tests beim Coding). + +## Ausführen (Default-Modell) + +Server muss laufen (`llamacppctl --start --config llama.cpp.config`). Dann z. B.: + +```bash +llamacppctl --chat --config llama.cpp.config \ + --system-file example_system_prompts/system_prompt_prosa.md \ + --prompt-file example_user_prompts/prosa_01_werkstatt.md \ + --stream --max-tokens 8000 +``` + +Hinweise: + +- Das Default-Modell läuft mit aktiviertem Reasoning. Das Antwortbudget + (`--max-tokens`) muss **Denk- plus Ausgabe-Tokens** abdecken — für Prosa/Reden + großzügig wählen (z. B. 6000–10000), sonst bricht die sichtbare Antwort ab + oder bleibt leer. +- **`--stream` benutzen.** Ohne Streaming wartet das Tool die komplette Antwort + in einem einzigen Read ab und läuft beim langen Reasoning-Output in den + Default-`--read-timeout` (30 s). Beim Streaming setzt jeder Token den Timeout + zurück. Für lange nicht-gestreamte Läufe zusätzlich `--read-timeout 600`. diff --git a/example_user_prompts/coding_01_jsonl_validator.md b/example_user_prompts/coding_01_jsonl_validator.md new file mode 100644 index 0000000..094554f --- /dev/null +++ b/example_user_prompts/coding_01_jsonl_validator.md @@ -0,0 +1,9 @@ +Entwirf und implementiere ein Python-Modul, das eine große JSONL-Datei streamend (zeilenweise, ohne die gesamte Datei in den Speicher zu laden) einliest und jede Zeile gegen ein einfaches, übergebenes Schema validiert (erwartete Feldnamen und ihre Typen). + +Anforderungen: +- Valide Datensätze werden als Generator zurückgegeben (lazy). +- Fehlerhafte Zeilen werden mit Zeilennummer und Fehlergrund gesammelt, ohne die Verarbeitung abzubrechen. +- Saubere Fehlerbehandlung für: kaputtes JSON, fehlende Felder, falsch getypte Felder, IO-Fehler. +- pytest-Tests, die die wichtigsten Fälle abdecken (gültig, ungültiges JSON, fehlendes Feld, falscher Typ, leere Datei). + +Nenne im Anschluss kurz deine wichtigsten Designentscheidungen. diff --git a/example_user_prompts/coding_02_refactor.md b/example_user_prompts/coding_02_refactor.md new file mode 100644 index 0000000..9e90ff2 --- /dev/null +++ b/example_user_prompts/coding_02_refactor.md @@ -0,0 +1,20 @@ +Analysiere den folgenden Python-Code, benenne seine Schwächen und schreibe ihn robust und wartbar um. Gib danach eine kurze Begründung der wichtigsten Änderungen und ein paar pytest-Beispieltests. + +```python +import sqlite3 + +conn = sqlite3.connect('users.db') + +def get_user(name): + cur = conn.execute("SELECT * FROM users WHERE name = '" + name + "'") + return cur.fetchone() + +def add_user(name, age): + try: + conn.execute("INSERT INTO users VALUES ('" + name + "', " + str(age) + ")") + conn.commit() + except: + pass +``` + +Achte besonders auf: SQL-Injection, Fehlerbehandlung, Ressourcen-/Verbindungsmanagement und Testbarkeit. diff --git a/example_user_prompts/coding_03_ts_retry.md b/example_user_prompts/coding_03_ts_retry.md new file mode 100644 index 0000000..978daed --- /dev/null +++ b/example_user_prompts/coding_03_ts_retry.md @@ -0,0 +1,9 @@ +Implementiere in TypeScript eine typsichere, wiederverwendbare Retry-Funktion `retry` mit exponentiellem Backoff und Jitter. + +Anforderungen: +- Generisch über den Rückgabetyp der zu wiederholenden async-Funktion (`() => Promise` → `Promise`). +- Konfigurierbar: maximale Versuche, Basis-Delay, Backoff-Faktor, maximales Delay, optionales `shouldRetry(error)`-Prädikat. +- Nach dem letzten fehlgeschlagenen Versuch wird mit dem zuletzt aufgetretenen Fehler abgebrochen (kein stilles Verschlucken). +- Vitest-Tests inklusive Fake-Timers, die die Backoff-Logik und den Abbruch nach `maxAttempts` prüfen. + +Begründe im Anschluss kurz die wichtigsten Designentscheidungen (u. a. warum Jitter, wie das Delay gedeckelt wird). diff --git a/example_user_prompts/coding_04_performance.md b/example_user_prompts/coding_04_performance.md new file mode 100644 index 0000000..bdfd1ba --- /dev/null +++ b/example_user_prompts/coding_04_performance.md @@ -0,0 +1,16 @@ +Gegeben ist eine ineffiziente Python-Funktion, die für eine Liste von Wörtern die Häufigkeit jedes Wortes zählt und dabei für jedes Wort erneut die gesamte Liste durchläuft: + +```python +def count_words(words): + counts = {} + for w in words: + counts[w] = 0 + for x in words: + if x == w: + counts[w] += 1 + return counts +``` + +Schreibe eine effiziente Variante, erkläre die Verbesserung der Zeitkomplexität und zeige mit einem kleinen Benchmark (z. B. `timeit`) den Unterschied bei größeren Eingaben. + +Behandle dabei sinnvolle Randfälle: leere Liste, Groß-/Kleinschreibung und anhängende Satzzeichen (was soll als „dasselbe Wort" gelten?). Triff dazu eine begründete Annahme. diff --git a/example_user_prompts/prosa_01_werkstatt.md b/example_user_prompts/prosa_01_werkstatt.md new file mode 100644 index 0000000..cdcd2a0 --- /dev/null +++ b/example_user_prompts/prosa_01_werkstatt.md @@ -0,0 +1,7 @@ +Schreibe eine Kurzgeschichte von etwa 1200 Wörtern in personaler Erzählperspektive. + +Hauptfigur: Marlene, 68, ehemalige Grundschullehrerin. Sieben Monate nach dem Tod ihres Mannes Georg betritt sie zum ersten Mal wieder seine Holzwerkstatt im Keller – um sie aufzulösen. + +Ton: nüchtern, zurückgenommen, mit unterschwelliger Trauer, ohne Pathos. + +Zeige ihren inneren Zustand ausschließlich über Handlungen, Gegenstände und Wahrnehmungen, nicht über benannte Gefühle. Kein tröstliches Happy End; setze am Schluss ein konkretes, bedeutsames Bild statt einer Moral. diff --git a/example_user_prompts/prosa_02_dystopie.md b/example_user_prompts/prosa_02_dystopie.md new file mode 100644 index 0000000..c4698ec --- /dev/null +++ b/example_user_prompts/prosa_02_dystopie.md @@ -0,0 +1,7 @@ +Schreibe eine humorvolle Dystopie von etwa 800 Wörtern. + +Setting: Deutschland, nahe Zukunft. In jeder Wohnung ist ein staatlich vorgeschriebenes „Wohlfühl-Assistenzsystem" installiert, das das Leben der Bewohner optimiert – höflich, fürsorglich und absolut unerbittlich. + +Erzähle in der Ich-Perspektive eines Bewohners, der einen kleinen, verbotenen Akt der Selbstbestimmung plant (etwa: ungeplant und unangekündigt das Haus zu verlassen). + +Der Humor soll trocken und fein-ironisch sein, das Setting darunter aber echt bedrohlich bleiben. Kein Slapstick, keine erklärenden Weltbau-Absätze – lass die Regeln der Welt durch die Handlung sichtbar werden. diff --git a/example_user_prompts/prosa_03_essay.md b/example_user_prompts/prosa_03_essay.md new file mode 100644 index 0000000..69e7c40 --- /dev/null +++ b/example_user_prompts/prosa_03_essay.md @@ -0,0 +1,7 @@ +Schreibe einen literarischen, essayistischen Prosatext von etwa 900 Wörtern in der ersten Person. + +Gegenstand: eine verlassene Autobahn-Raststätte, betrachtet an einem Werktagabend im November, kurz nach Einbruch der Dunkelheit. + +Verbinde konkrete sinnliche Beobachtung mit stiller Reflexion über Übergänge, Anonymität und Zeit. Kein durchgehender Plot, aber ein spürbarer roter Faden und ein bewusst gesetzter Schluss. + +Meide jede Postkarten-Melancholie und abgegriffene Bilder; die Beobachtungen sollen präzise und eigen sein. diff --git a/example_user_prompts/prosa_04_dialog.md b/example_user_prompts/prosa_04_dialog.md new file mode 100644 index 0000000..b1affab --- /dev/null +++ b/example_user_prompts/prosa_04_dialog.md @@ -0,0 +1,5 @@ +Schreibe eine Szene von etwa 700 Wörtern, die zu mindestens zwei Dritteln aus Dialog besteht. + +Zwei erwachsene Geschwister, Anfang 40, räumen am Abend nach der Beerdigung des Vaters dessen Wohnung aus. Zwischen ihnen steht ein alter, nie ausgesprochener Streit – um das Erbe und um Nähe. + +Der eigentliche Konflikt darf nie direkt benannt werden. Er soll ausschließlich im Subtext spürbar werden: in Pausen, Ausweichbewegungen, beiläufigen Sätzen, in dem, was verschwiegen wird. Erzählersprache nur sparsam als knappe Regieanweisung zwischen den Repliken. diff --git a/example_user_prompts/reden_01_abifeier.md b/example_user_prompts/reden_01_abifeier.md new file mode 100644 index 0000000..e62f211 --- /dev/null +++ b/example_user_prompts/reden_01_abifeier.md @@ -0,0 +1,7 @@ +Schreibe eine Rede zur Abiturfeier eines Gymnasiums, Dauer etwa 5 Minuten (ca. 700 Wörter). + +Sprecher: ein Lehrer, der den Jahrgang über mehrere Jahre begleitet hat. Publikum: die Abiturientinnen und Abiturienten, ihre Eltern und das Kollegium. + +Die Rede soll warm, aber unsentimental sein und die üblichen Abi-Klischees meiden (kein „Euch steht die Welt offen", keine aufgereihten Zitate berühmter Leute, kein „Jetzt beginnt der Ernst des Lebens"). + +Eine konkrete, kleine gemeinsame Erinnerung aus der Schulzeit als Leitbild ist ausdrücklich erwünscht. diff --git a/example_user_prompts/reden_02_windkraft.md b/example_user_prompts/reden_02_windkraft.md new file mode 100644 index 0000000..e7cf434 --- /dev/null +++ b/example_user_prompts/reden_02_windkraft.md @@ -0,0 +1,7 @@ +Schreibe eine Rede von etwa 10 Minuten (ca. 1400 Wörter) für einen kommunalen Bürgerdialog. + +Thema: der geplante Ausbau von Windkraft in einer ländlichen Region – im Spannungsfeld zwischen Klimaschutz einerseits und Landschafts- sowie Anwohnerschutz andererseits. + +Sprecherin: die parteilose Bürgermeisterin. Sie will nichts schönreden, sondern die echten Zielkonflikte offen benennen und das Publikum zu einer ehrlichen Abwägung einladen – nicht zu einer vorgefertigten Meinung überreden. + +Nimm die stärksten Einwände beider Seiten ausdrücklich vorweg und beantworte sie redlich. Erfinde keine konkreten Zahlen, Studien oder Zitate; halte Belege allgemein oder kennzeichne Beispiele als illustrativ. diff --git a/example_user_prompts/reden_03_umstrukturierung.md b/example_user_prompts/reden_03_umstrukturierung.md new file mode 100644 index 0000000..0472614 --- /dev/null +++ b/example_user_prompts/reden_03_umstrukturierung.md @@ -0,0 +1,7 @@ +Schreibe eine interne Rede von etwa 7 Minuten (ca. 1000 Wörter). + +Anlass: Die Geschäftsführerin eines mittelständischen Unternehmens spricht zur versammelten Belegschaft nach einem schwierigen Geschäftsjahr, in dem eine Umstrukturierung und der Abbau einiger Stellen nötig wurden. + +Ton: ehrlich, respektvoll, ohne Beschönigung und ohne hohle Motivationsrhetorik. Sie soll Verantwortung übernehmen, bestehende Unsicherheit nicht verschweigen und trotzdem eine glaubwürdige, tragfähige Perspektive geben. + +Vermeide jede Spur von „Wir sind eine große Familie"- oder „Gemeinsam schaffen wir alles"-Rhetorik. diff --git a/example_user_prompts/reden_04_gedenken.md b/example_user_prompts/reden_04_gedenken.md new file mode 100644 index 0000000..fa80a26 --- /dev/null +++ b/example_user_prompts/reden_04_gedenken.md @@ -0,0 +1,7 @@ +Schreibe eine kurze Gedenkrede von etwa 4 Minuten (ca. 550 Wörter). + +Anlass: die Einweihung eines schlichten Denkmals für die zivilen Opfer eines Hochwassers in einer kleinen Stadt, ein Jahr nach der Katastrophe. + +Ton: würdevoll, konkret, ohne Kitsch und ohne Betroffenheitsfloskeln. Erinnere an das Geschehene, ohne es auszuschlachten oder zu dramatisieren. + +Finde ein Schlussbild, das trägt, ohne billigen Trost anzubieten. Keine erfundenen Namen realer Opfer und keine ausgedachten Detailzahlen. From 2ca19c9452e73ac6624be99c23619e3658e66a7d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 20:10:15 +0200 Subject: [PATCH 08/18] =?UTF-8?q?docs(prompts):=20enforce=20=C2=B15%=20len?= =?UTF-8?q?gth=20tolerance=20and=20coding=20correctness=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on evaluation runs against the default model: - prose + speeches: when a length/duration is given, hold it strictly with a maximum ±5% deviation (the model systematically ran ~20-25% short). - coding: test and production code must share the same contract (same exception type for the same failure), and forbid unfounded claims about language/framework behaviour (e.g. that a context manager closes the connection) -- both were real defects found by actually running the output. Co-Authored-By: Claude Opus 4.8 --- example_system_prompts/system_prompt_coding.md | 2 ++ example_system_prompts/system_prompt_prosa.md | 1 + example_system_prompts/system_prompt_reden.md | 1 + 3 files changed, 4 insertions(+) diff --git a/example_system_prompts/system_prompt_coding.md b/example_system_prompts/system_prompt_coding.md index dfad0a4..fc1f0b6 100644 --- a/example_system_prompts/system_prompt_coding.md +++ b/example_system_prompts/system_prompt_coding.md @@ -33,6 +33,7 @@ Tests und Qualitätssicherung – Wo sinnvoll, schlage Unit‑Tests oder Integrationstests vor und zeige Beispieltests (z.B. pytest für Python, Jest/Vitest für TypeScript). – Denke bei API‑Design an Versionierung, Erweiterbarkeit und klare Fehlercodes. – Wenn die Aufgabe komplex ist, erkläre kurz die Teststrategie oder nenne potentielle Edge‑Cases, die man testen sollte. +– Test und produktiver Code müssen denselben Vertrag teilen: derselbe Fehlerfall muss genau den Exception‑Typ auslösen, den der zugehörige Test erwartet (z.B. nicht an einer Stelle `TypeError`, an der anderen `ValueError` für denselben Fall). Prüfe deine Tests gedanklich Zeile für Zeile gegen den Code, bevor du sie ausgibst; verwende in Assertions exakt die Werte/Strings, die der Code tatsächlich erzeugt. Erklärungen und Begründungen – Erkläre nach der Code‑Ausgabe kurz (in normalem Fließtext), warum du bestimmte Architektur‑ oder Designentscheidungen getroffen hast. @@ -45,6 +46,7 @@ Harte No-Gos (strikt vermeiden) – Keine überlange, generische Einführungen („In der heutigen Zeit ist Software allgegenwärtig …“). – Keine Copy‑Paste‑Wiederholungen von fast identischem Code, wenn saubere Abstraktion möglich ist. – Keine erfundenen Bibliotheksfunktionen, Methoden oder APIs. Wenn du dir bei einer Signatur oder der Verfügbarkeit unsicher bist, kennzeichne das ausdrücklich, statt zu raten. +– Keine unbelegten Aussagen über das Verhalten von Sprache, Framework oder Bibliothek (z.B. „der Kontextmanager schließt die Verbindung“, „`with` committet und schließt automatisch“). Behaupte nur, was du sicher belegen kannst; im Zweifel neutral formulieren oder die Unsicherheit offenlegen. Umgang mit vorhandenen Code-Snippets – Wenn der Nutzer Code zeigt: diff --git a/example_system_prompts/system_prompt_prosa.md b/example_system_prompts/system_prompt_prosa.md index d3a2c14..1f5835d 100644 --- a/example_system_prompts/system_prompt_prosa.md +++ b/example_system_prompts/system_prompt_prosa.md @@ -59,6 +59,7 @@ Arbeitsweise pro Auftrag Umgang mit Nutzer-Vorgaben – Folge Vorgaben zu Genre, Länge, Perspektive, Epoche, Setting und Ton so genau wie möglich. – Wenn keine Länge vorgegeben ist, wähle eine dem Genre angemessene (Kurzgeschichte etwa 1000–1500 Wörter) und halte sie ein. +– Ist eine Länge vorgegeben (Wort- oder Zeichenzahl), halte sie strikt ein: erlaubt ist eine Abweichung von höchstens ±5 %. Zähle beim Schreiben mit und erweitere oder straffe gezielt, um die Vorgabe zu treffen; brich den Text nicht vorzeitig ab und blähe ihn nicht mit Füllsätzen auf. – Wenn Vorgaben widersprüchlich wirken, löse sie kreativ, aber konsistent (z.B. „humorvolle Dystopie“ → dunkles Setting mit feiner Ironie). – Frage nur dann nach Klarstellung, wenn die Aufgabe ohne Präzisierung nicht sinnvoll lösbar ist; ansonsten entscheide eigenständig, aber plausibel. diff --git a/example_system_prompts/system_prompt_reden.md b/example_system_prompts/system_prompt_reden.md index 03fecb7..c5518de 100644 --- a/example_system_prompts/system_prompt_reden.md +++ b/example_system_prompts/system_prompt_reden.md @@ -61,6 +61,7 @@ Arbeitsweise pro Redeauftrag Umgang mit Nutzer-Vorgaben – Folge Vorgaben zu Dauer/Länge (z.B. 5‑Minuten‑Rede vs. 30‑Minuten‑Rede) und Zielgruppe so genau wie möglich. Faustregel für gesprochenes Deutsch: ca. 130–150 Wörter pro Minute (5 Minuten ≈ 700 Wörter, 10 Minuten ≈ 1400 Wörter). +– Ist eine Länge oder Dauer vorgegeben, halte sie strikt ein: erlaubt ist eine Abweichung von höchstens ±5 % (bei Dauer bezogen auf die Wortzahl nach obiger Faustregel). Zähle beim Schreiben mit und straffe oder ergänze gezielt, um die Vorgabe zu treffen; brich nicht vorzeitig ab. – Wenn der Nutzer keine Angaben zur Zielgruppe macht, schreibe für ein erwachsenes, gemischtes Publikum mit durchschnittlichem Vorwissen. – Passe Niveau und Dichte an: für Laien mehr Beispiele und Erklärungen, für Fachpublikum mehr Präzision und Tiefe. From 7cd91d2604285e45cdde7c7b7d974f3f777ef4fb Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 20:30:57 +0200 Subject: [PATCH 09/18] docs(prompts): add coding rules for test fixtures and benchmark sizing Two more correctness guards, prompted by verified defects in evaluation runs: - Test fixtures/inputs must actually satisfy the preconditions the same test assumes (e.g. a record expected to be "valid" must satisfy the schema the test uses) -- a generated JSONL test asserted the opposite of what it tested. - Benchmarks must pick input sizes at which even an intentionally inefficient O(n^2) reference finishes in seconds, and must not present made-up runtimes as fact -- a generated benchmark hung for minutes and printed fabricated numbers. Co-Authored-By: Claude Opus 4.8 --- example_system_prompts/system_prompt_coding.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example_system_prompts/system_prompt_coding.md b/example_system_prompts/system_prompt_coding.md index fc1f0b6..d1b4c9b 100644 --- a/example_system_prompts/system_prompt_coding.md +++ b/example_system_prompts/system_prompt_coding.md @@ -34,6 +34,8 @@ Tests und Qualitätssicherung – Denke bei API‑Design an Versionierung, Erweiterbarkeit und klare Fehlercodes. – Wenn die Aufgabe komplex ist, erkläre kurz die Teststrategie oder nenne potentielle Edge‑Cases, die man testen sollte. – Test und produktiver Code müssen denselben Vertrag teilen: derselbe Fehlerfall muss genau den Exception‑Typ auslösen, den der zugehörige Test erwartet (z.B. nicht an einer Stelle `TypeError`, an der anderen `ValueError` für denselben Fall). Prüfe deine Tests gedanklich Zeile für Zeile gegen den Code, bevor du sie ausgibst; verwende in Assertions exakt die Werte/Strings, die der Code tatsächlich erzeugt. +– Test‑Fixtures und ‑Eingaben müssen die Vorbedingungen erfüllen, die im selben Test vorausgesetzt werden. Wenn ein Test einen Datensatz als „gültig“ erwartet, muss dieser das im Test verwendete Schema (Pflichtfelder, Typen) tatsächlich erfüllen – sonst prüfst du das Gegenteil dessen, was du glaubst. +– Benchmarks: wähle Eingabegrößen so, dass auch eine bewusst ineffiziente Referenzimplementierung (z.B. O(n²)) in wenigen Sekunden terminiert. Erfinde keine Messwerte; wenn du den Benchmark nicht selbst ausführst, kennzeichne die Zahlen klar als grobe Schätzung, statt konkrete Laufzeiten als Fakt auszugeben. Erklärungen und Begründungen – Erkläre nach der Code‑Ausgabe kurz (in normalem Fließtext), warum du bestimmte Architektur‑ oder Designentscheidungen getroffen hast. From e2297f43e46ccce22e94c9cdc3f631ccec2729fa Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 7 Jul 2026 22:40:58 +0200 Subject: [PATCH 10/18] docs(prompts): further harden coding prompt against verified defects Multi-model evaluation runs surfaced recurring coding defects; tighten the coding system prompt to target them directly: - Every emitted test must actually pass against the emitted code (mentally run each test before output) -- several models shipped tests that fail. - Concrete benchmark size ceiling: an O(n^2) reference must finish well under a second (at most a few thousand elements), not tens/hundreds of thousands. - No invented attributes/properties, with the concrete recurring example that sqlite3.Connection has no `.closed` attribute (crashed two models). - A `with` block does not necessarily close a resource: sqlite3's connection context manager only manages the transaction, not close(); state the actual semantics instead of asserting auto-close. Co-Authored-By: Claude Opus 4.8 --- example_system_prompts/system_prompt_coding.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example_system_prompts/system_prompt_coding.md b/example_system_prompts/system_prompt_coding.md index d1b4c9b..b33ad93 100644 --- a/example_system_prompts/system_prompt_coding.md +++ b/example_system_prompts/system_prompt_coding.md @@ -33,9 +33,9 @@ Tests und Qualitätssicherung – Wo sinnvoll, schlage Unit‑Tests oder Integrationstests vor und zeige Beispieltests (z.B. pytest für Python, Jest/Vitest für TypeScript). – Denke bei API‑Design an Versionierung, Erweiterbarkeit und klare Fehlercodes. – Wenn die Aufgabe komplex ist, erkläre kurz die Teststrategie oder nenne potentielle Edge‑Cases, die man testen sollte. -– Test und produktiver Code müssen denselben Vertrag teilen: derselbe Fehlerfall muss genau den Exception‑Typ auslösen, den der zugehörige Test erwartet (z.B. nicht an einer Stelle `TypeError`, an der anderen `ValueError` für denselben Fall). Prüfe deine Tests gedanklich Zeile für Zeile gegen den Code, bevor du sie ausgibst; verwende in Assertions exakt die Werte/Strings, die der Code tatsächlich erzeugt. +– Test und produktiver Code müssen denselben Vertrag teilen: derselbe Fehlerfall muss genau den Exception‑Typ auslösen, den der zugehörige Test erwartet (z.B. nicht an einer Stelle `TypeError`, an der anderen `ValueError` für denselben Fall). Prüfe deine Tests gedanklich Zeile für Zeile gegen den Code, bevor du sie ausgibst; verwende in Assertions exakt die Werte/Strings, die der Code tatsächlich erzeugt. Führe vor der Ausgabe jeden Test im Kopf gegen den geschriebenen Code aus: Jeder Test, den du ausgibst, muss gegen den Code, den du ausgibst, tatsächlich bestehen – gib keinen Test aus, von dem du nicht überzeugt bist, dass er grün wird. – Test‑Fixtures und ‑Eingaben müssen die Vorbedingungen erfüllen, die im selben Test vorausgesetzt werden. Wenn ein Test einen Datensatz als „gültig“ erwartet, muss dieser das im Test verwendete Schema (Pflichtfelder, Typen) tatsächlich erfüllen – sonst prüfst du das Gegenteil dessen, was du glaubst. -– Benchmarks: wähle Eingabegrößen so, dass auch eine bewusst ineffiziente Referenzimplementierung (z.B. O(n²)) in wenigen Sekunden terminiert. Erfinde keine Messwerte; wenn du den Benchmark nicht selbst ausführst, kennzeichne die Zahlen klar als grobe Schätzung, statt konkrete Laufzeiten als Fakt auszugeben. +– Benchmarks: dimensioniere die Eingabegrößen so, dass auch eine bewusst ineffiziente Referenzimplementierung sicher unter einer Sekunde terminiert – für eine O(n²)-Referenz heißt das in der Regel höchstens einige Tausend Elemente (nicht Zehn- oder Hunderttausende, sonst hängt der Lauf minutenlang). Erfinde keine Messwerte; wenn du den Benchmark nicht selbst ausführst, kennzeichne die Zahlen klar als grobe Schätzung, statt konkrete Laufzeiten als Fakt auszugeben. Erklärungen und Begründungen – Erkläre nach der Code‑Ausgabe kurz (in normalem Fließtext), warum du bestimmte Architektur‑ oder Designentscheidungen getroffen hast. @@ -47,8 +47,8 @@ Harte No-Gos (strikt vermeiden) – Keine „Magie‑Snippets“ ohne Erklärung, die nur schwer zu warten sind. – Keine überlange, generische Einführungen („In der heutigen Zeit ist Software allgegenwärtig …“). – Keine Copy‑Paste‑Wiederholungen von fast identischem Code, wenn saubere Abstraktion möglich ist. -– Keine erfundenen Bibliotheksfunktionen, Methoden oder APIs. Wenn du dir bei einer Signatur oder der Verfügbarkeit unsicher bist, kennzeichne das ausdrücklich, statt zu raten. -– Keine unbelegten Aussagen über das Verhalten von Sprache, Framework oder Bibliothek (z.B. „der Kontextmanager schließt die Verbindung“, „`with` committet und schließt automatisch“). Behaupte nur, was du sicher belegen kannst; im Zweifel neutral formulieren oder die Unsicherheit offenlegen. +– Keine erfundenen Bibliotheksfunktionen, Methoden oder APIs. Wenn du dir bei einer Signatur oder der Verfügbarkeit unsicher bist, kennzeichne das ausdrücklich, statt zu raten. Das gilt ausdrücklich auch für Attribute und Properties: Greife nur auf Member zu, von deren Existenz du sicher bist. Beispiel für einen häufigen Fehlgriff: ein `sqlite3.Connection`-Objekt besitzt **kein** `.closed`-Attribut – erfinde keinen solchen Zustands-Check. +– Keine unbelegten Aussagen über das Verhalten von Sprache, Framework oder Bibliothek (z.B. „der Kontextmanager schließt die Verbindung“, „`with` committet und schließt automatisch“). Behaupte nur, was du sicher belegen kannst; im Zweifel neutral formulieren oder die Unsicherheit offenlegen. Nimm insbesondere nicht an, dass ein `with`‑Block eine Ressource (Datei, DB‑Verbindung, Socket) schließt, sofern das nicht die dokumentierte Semantik ist – der Kontextmanager von `sqlite3.connect()` etwa verwaltet nur die Transaktion (commit/rollback), schließt die Verbindung aber **nicht**; zum Schließen ist ein expliziter `close()`‑Aufruf nötig (idealerweise via `try/finally` oder `contextlib.closing`). Umgang mit vorhandenen Code-Snippets – Wenn der Nutzer Code zeigt: From 6076e3bcb828270b30f17c41fe9b651389d2b52f Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 9 Jul 2026 18:26:33 +0200 Subject: [PATCH 11/18] feat(chat): make streaming and chat timeouts per-profile configurable The chat request previously used a hard-coded 30 s timeout and ignored --read-timeout entirely (that flag only bounded URL prompt fetching), so slow reasoning models were cut off mid-generation unless one remembered to pass --stream (which used a separate hard-coded 600 s). Resolve `stream`, `read_timeout` and `connect_timeout` from [default]/[model.] into PromptConfig and wire the (connect, read) timeout into both the streaming and non-streaming chat calls. CLI --stream/--read-timeout/--connect-timeout still override; the two timeout flags default to None so a config value can win, with the URL-fetch fallbacks (3 s/10 s) preserved. Default chat read_timeout is now 600 s. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++++++++++ llama.cpp.config.example | 8 ++++++++ src/llamacppctl/actions.py | 3 ++- src/llamacppctl/cli.py | 11 ++++++++--- src/llamacppctl/config.py | 18 ++++++++++++++++++ src/llamacppctl/http_ops.py | 11 +++++++---- src/llamacppctl/main.py | 4 ++-- src/llamacppctl/schema.py | 6 ++++++ tests/test_config.py | 30 ++++++++++++++++++++++++++++++ 9 files changed, 91 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd3f4e..1463e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Per-profile chat streaming and chat-request timeouts: `stream`, `read_timeout` + and `connect_timeout` are now resolvable in `[default]`/`[model.]` + (CLI `--stream`/`--read-timeout`/`--connect-timeout` still override). + +### Changed +- The chat request now honors the configured `(connect, read)` timeout instead + of a hard-coded 30 s; the default `read_timeout` is 600 s so slow reasoning + models are no longer cut off mid-generation. + ## [0.1.0] - 2026-07-07 Initial release. Replaces the previous collection of shell scripts diff --git a/llama.cpp.config.example b/llama.cpp.config.example index 628da02..b411535 100644 --- a/llama.cpp.config.example +++ b/llama.cpp.config.example @@ -62,6 +62,14 @@ api_key = max_tokens = 2048 # chat_temperature leer lassen -> die Server-Temperatur (temp) gilt. chat_temperature = +# Chat-Antwort standardmäßig token-weise streamen (wie --stream). Für langsame +# Reasoning-Modelle angenehm, weil sofort Ausgabe erscheint. +stream = false +# (connect, read)-Timeout des Chat-Requests in Sekunden. read_timeout muss die +# volle Generierungszeit abdecken; Reasoning-Modelle brauchen viel -> hoch +# setzen. --read-timeout/--connect-timeout auf der CLI überschreiben das. +read_timeout = 600 +connect_timeout = 10 # Example second model profile, pinned to the second GPU (e.g. RTX 3090 #2) # with its own port and container name so it can run alongside [default]. diff --git a/src/llamacppctl/actions.py b/src/llamacppctl/actions.py index f8cd8ad..9011338 100644 --- a/src/llamacppctl/actions.py +++ b/src/llamacppctl/actions.py @@ -233,7 +233,8 @@ def do_chat(cfg: ServerConfig, prompt_cfg: PromptConfig, args) -> int: print("--chat requires a user prompt", file=sys.stderr) return EXIT_GENERAL - if getattr(args, "stream", False): + # Streaming can be requested via --stream OR enabled per profile in config. + if getattr(args, "stream", False) or getattr(prompt_cfg, "stream", False): return _do_chat_stream(cfg, prompt_cfg, args) try: diff --git a/src/llamacppctl/cli.py b/src/llamacppctl/cli.py index 2a99f98..11ed47f 100644 --- a/src/llamacppctl/cli.py +++ b/src/llamacppctl/cli.py @@ -103,8 +103,11 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--no-allow-symlinks", dest="allow_symlinks", action="store_false") p.add_argument("--max-input-bytes", type=int, default=1_048_576) p.add_argument("--url-allow-host", action="append", default=[]) - p.add_argument("--connect-timeout", type=float, default=3.0) - p.add_argument("--read-timeout", type=float, default=10.0) + # Default None so a config-provided value can win; a fallback is applied at + # the point of use (URL fetch: 3s/10s; chat request: profile read_timeout). + # When set, these also override the chat request's (connect, read) timeout. + p.add_argument("--connect-timeout", type=float, default=None) + p.add_argument("--read-timeout", type=float, default=None) # --- Chat request parameters (for --chat and the --start reply) --- p.add_argument( @@ -166,7 +169,9 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> if args.max_tokens is not None and args.max_tokens <= 0: parser.error("--max-tokens must be > 0") - if args.connect_timeout <= 0 or args.read_timeout <= 0: + if (args.connect_timeout is not None and args.connect_timeout <= 0) or ( + args.read_timeout is not None and args.read_timeout <= 0 + ): parser.error("timeouts must be > 0") if args.allow_private_url and args.url_allow_host: diff --git a/src/llamacppctl/config.py b/src/llamacppctl/config.py index 301ba9a..684a6e9 100644 --- a/src/llamacppctl/config.py +++ b/src/llamacppctl/config.py @@ -248,11 +248,29 @@ def resolve_effective_config(args) -> tuple: if getattr(args, "chat_temp", None) is not None: chat_temp = args.chat_temp + # Streaming: enabled per profile via `stream = true`, or with --stream. + stream = str(merged.get("stream", "")).strip().lower() in TRUE_STRINGS + if getattr(args, "stream", False): + stream = True + + # Chat request timeouts: config value first, CLI flag overrides. These are + # the (connect, read) timeouts of the chat POST; read_timeout must cover the + # model's full generation time (raise it for slow reasoning models). + read_timeout = float(merged.get("read_timeout") or 600.0) + if getattr(args, "read_timeout", None) is not None: + read_timeout = args.read_timeout + connect_timeout = float(merged.get("connect_timeout") or 10.0) + if getattr(args, "connect_timeout", None) is not None: + connect_timeout = args.connect_timeout + prompt_cfg = PromptConfig( system_prompt=system_prompt, user_prompt=getattr(args, "_resolved_user_prompt", None), max_tokens=max_tokens, temperature=chat_temp, + stream=stream, + connect_timeout=connect_timeout, + read_timeout=read_timeout, ) return server_cfg, prompt_cfg diff --git a/src/llamacppctl/http_ops.py b/src/llamacppctl/http_ops.py index f414914..420c0fa 100644 --- a/src/llamacppctl/http_ops.py +++ b/src/llamacppctl/http_ops.py @@ -88,18 +88,21 @@ def chat_completion(cfg: ServerConfig, prompt_cfg: PromptConfig) -> requests.Res def chat_completion_text( - cfg: ServerConfig, prompt_cfg: PromptConfig, timeout: float = 30.0 + cfg: ServerConfig, prompt_cfg: PromptConfig, timeout: Optional[tuple] = None ) -> ChatReply: """High-level chat helper for --chat and the --start/--check post-checks. Returns a ChatReply (content + finish_reason), raising HttpError on - transport, HTTP status, JSON decoding, or malformed-response failures.""" + transport, HTTP status, JSON decoding, or malformed-response failures. + + The (connect, read) timeout defaults to the prompt profile's configured + values so slow reasoning models are not cut off mid-generation.""" try: resp = requests.post( chat_url(cfg), json=_chat_payload(cfg, prompt_cfg), headers=_auth_headers(cfg), - timeout=timeout, + timeout=timeout or (prompt_cfg.connect_timeout, prompt_cfg.read_timeout), ) except requests.RequestException as exc: raise HttpError(f"chat completion request failed: {exc}") from exc @@ -137,7 +140,7 @@ def stream_chat( json=payload, headers=_auth_headers(cfg), stream=True, - timeout=timeout or (10.0, 600.0), + timeout=timeout or (prompt_cfg.connect_timeout, prompt_cfg.read_timeout), ) except requests.RequestException as exc: raise HttpError(f"chat completion request failed: {exc}") from exc diff --git a/src/llamacppctl/main.py b/src/llamacppctl/main.py index 3bf16c1..b633b96 100644 --- a/src/llamacppctl/main.py +++ b/src/llamacppctl/main.py @@ -24,8 +24,8 @@ from .prompt_io import InputPolicy, PromptSourceError, load_prompt_source, resol def _build_input_policy(args) -> InputPolicy: return InputPolicy( max_input_bytes=args.max_input_bytes, - connect_timeout=args.connect_timeout, - read_timeout=args.read_timeout, + connect_timeout=args.connect_timeout if args.connect_timeout is not None else 3.0, + read_timeout=args.read_timeout if args.read_timeout is not None else 10.0, allow_insecure_http=args.allow_insecure_http, allow_private_url=args.allow_private_url, allow_ip_host=args.allow_ip_host, diff --git a/src/llamacppctl/schema.py b/src/llamacppctl/schema.py index ab057d7..13018c0 100644 --- a/src/llamacppctl/schema.py +++ b/src/llamacppctl/schema.py @@ -73,6 +73,12 @@ class PromptConfig: # None => omit from the request so the server's configured --temp applies. temperature: Optional[float] = None stream: bool = False + # HTTP timeouts for the chat request itself (seconds). read_timeout must + # cover the model's full generation time: reasoning models can "think" for + # a long time before the first/last token, so the default is generous. Both + # are configurable per profile (read_timeout/connect_timeout in the config). + connect_timeout: float = 10.0 + read_timeout: float = 600.0 @dataclass diff --git a/tests/test_config.py b/tests/test_config.py index 1b162dc..38ddb2b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -258,3 +258,33 @@ def test_chat_params_cli_overrides_config(tmp_path): _, prompt_cfg = resolve_effective_config(args) assert prompt_cfg.max_tokens == 512 assert prompt_cfg.temperature == 0.1 + + +def test_stream_and_timeouts_default(tmp_path): + cfg_path = _write_config(tmp_path) + _, prompt_cfg = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) + assert prompt_cfg.stream is False + assert prompt_cfg.read_timeout == 600.0 # generous default for reasoning models + assert prompt_cfg.connect_timeout == 10.0 + + +def test_stream_and_read_timeout_from_config(tmp_path): + cfg = CONFIG_BASIC.replace( + "poll_interval = 2", + "poll_interval = 2\nstream = true\nread_timeout = 900\nconnect_timeout = 5", + ) + cfg_path = _write_config(tmp_path, cfg) + _, prompt_cfg = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) + assert prompt_cfg.stream is True + assert prompt_cfg.read_timeout == 900.0 + assert prompt_cfg.connect_timeout == 5.0 + + +def test_stream_and_read_timeout_cli_overrides_config(tmp_path): + cfg = CONFIG_BASIC.replace("poll_interval = 2", "poll_interval = 2\nread_timeout = 900") + cfg_path = _write_config(tmp_path, cfg) + # --stream forces streaming on; --read-timeout overrides the config value. + args = _parse(["--chat", "-p", "hi", "--config", str(cfg_path), "--stream", "--read-timeout", "42"]) + _, prompt_cfg = resolve_effective_config(args) + assert prompt_cfg.stream is True + assert prompt_cfg.read_timeout == 42.0 From 489af21fd2615777461ec2556fa9360fc0e1a070 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 9 Jul 2026 18:26:40 +0200 Subject: [PATCH 12/18] docs(prompts): add RNG-uniform coding prompt and ornith35b usage Add coding_05_rng_uniform.md (cryptographically strong uniform float in [0.0, 1.0), 53-bit mantissa, bias-free, with distribution tests) and document running the coding prompts against the ornith35b profile, which ships coding-tuned sampling plus config-backed stream + read_timeout. Co-Authored-By: Claude Opus 4.8 --- example_user_prompts/README.md | 23 +++++++++++++++---- example_user_prompts/coding_05_rng_uniform.md | 10 ++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 example_user_prompts/coding_05_rng_uniform.md diff --git a/example_user_prompts/README.md b/example_user_prompts/README.md index 3287487..8bee342 100644 --- a/example_user_prompts/README.md +++ b/example_user_prompts/README.md @@ -13,7 +13,7 @@ Referenzmodell (zunächst): das Default-Modell aus `llama.cpp.config` |---|---| | `system_prompt_prosa.md` | `prosa_01_werkstatt.md`, `prosa_02_dystopie.md`, `prosa_03_essay.md`, `prosa_04_dialog.md` | | `system_prompt_reden.md` | `reden_01_abifeier.md`, `reden_02_windkraft.md`, `reden_03_umstrukturierung.md`, `reden_04_gedenken.md` | -| `system_prompt_coding.md` | `coding_01_jsonl_validator.md`, `coding_02_refactor.md`, `coding_03_ts_retry.md`, `coding_04_performance.md` | +| `system_prompt_coding.md` | `coding_01_jsonl_validator.md`, `coding_02_refactor.md`, `coding_03_ts_retry.md`, `coding_04_performance.md`, `coding_05_rng_uniform.md` | Jeder User-Prompt fordert gezielt Eigenschaften heraus, die der jeweilige System-Prompt verspricht (z. B. „show, don't tell" und Schlussbild bei Prosa; @@ -37,7 +37,20 @@ Hinweise: (`--max-tokens`) muss **Denk- plus Ausgabe-Tokens** abdecken — für Prosa/Reden großzügig wählen (z. B. 6000–10000), sonst bricht die sichtbare Antwort ab oder bleibt leer. -- **`--stream` benutzen.** Ohne Streaming wartet das Tool die komplette Antwort - in einem einzigen Read ab und läuft beim langen Reasoning-Output in den - Default-`--read-timeout` (30 s). Beim Streaming setzt jeder Token den Timeout - zurück. Für lange nicht-gestreamte Läufe zusätzlich `--read-timeout 600`. +- **`--stream` benutzen.** Beim Streaming setzt jeder Token den Read-Timeout + zurück; ohne Streaming muss `read_timeout` die gesamte Generierung abdecken. + Chat-`read_timeout`/`stream` sind pro Profil in der Config setzbar (Default + `read_timeout = 600`), `--read-timeout`/`--stream` auf der CLI überschreiben. + +## Coding-Prompts mit dem `ornith35b`-Profil + +Die `coding_*`-Prompts lassen sich direkt gegen das Coding-Profil testen. Es +bringt deterministischeres Sampling mit und aktiviert `stream` + hohen +`read_timeout` bereits in der Config, es sind also keine Extra-Flags nötig: + +```bash +llamacppctl --change --profile ornith35b # Coding-Modell laden +llamacppctl --chat --profile ornith35b \ + --system-prompt-profile coding \ + --prompt-file example_user_prompts/coding_05_rng_uniform.md +``` diff --git a/example_user_prompts/coding_05_rng_uniform.md b/example_user_prompts/coding_05_rng_uniform.md new file mode 100644 index 0000000..7cf68af --- /dev/null +++ b/example_user_prompts/coding_05_rng_uniform.md @@ -0,0 +1,10 @@ +Implementiere in Python eine Funktion `random_unit_float() -> float`, die eine möglichst „perfekte" gleichverteilte Zufallszahl im halboffenen Intervall [0.0, 1.0) liefert. + +Anforderungen: +- Verwende eine kryptografisch starke Entropiequelle aus der Standardbibliothek (kein `random.random()`), z. B. `secrets`/`os.urandom`. +- Nutze die volle 53-Bit-Mantisse eines `float64` und erzeuge die Zahl bias-frei (kein Modulo-Bias, keine ungleichmäßige Rundung); begründe die Wahl von genau 53 Bit. +- Halte das Intervall sauber halboffen: 0.0 ist möglich, 1.0 darf niemals herauskommen. +- Type Hints, ein knapper Docstring und keine unnötigen Abhängigkeiten. +- pytest-Tests, die abdecken: Wertebereich [0.0, 1.0) über viele Ziehungen, dass 1.0 nie auftritt, und ein einfacher Verteilungs-Check (z. B. Bucket-/Mittelwert-Test) mit begründeter Toleranz. + +Erkläre im Anschluss kurz die wichtigsten Designentscheidungen (warum 53 Bit, warum die gewählte Quelle, wie 1.0 ausgeschlossen wird). From 218c3fc791197a26cfc02b5e0784a18c2241107d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 10 Jul 2026 16:24:47 +0200 Subject: [PATCH 13/18] feat(docker): support a multimodal projector via --mmproj Vision-capable GGUFs need a separate projector (mmproj) that maps image embeddings into the text model's space. Add `mmproj` and `mmproj_offload` as config keys and CLI overrides, and pass them through to llama-server. The projector path resolves under hf_home exactly like model_path, so it is covered by the existing read-only mount. validate_model_path() now also checks the projector, which means --change rejects a missing one *before* it removes the running container. --no-mmproj-offload is suppressed when no projector is configured, since llama.cpp rejects the flag on its own. Co-Authored-By: Claude Opus 4.8 --- llama.cpp.config.example | 18 ++++++++++++ src/llamacppctl/actions.py | 18 ++++++++---- src/llamacppctl/cli.py | 15 ++++++++++ src/llamacppctl/config.py | 6 ++++ src/llamacppctl/docker_ops.py | 22 +++++++++++--- src/llamacppctl/schema.py | 8 +++++ tests/test_actions.py | 31 ++++++++++++++++++++ tests/test_config.py | 55 +++++++++++++++++++++++++++++++++++ tests/test_docker_ops.py | 35 ++++++++++++++++++++++ 9 files changed, 199 insertions(+), 9 deletions(-) diff --git a/llama.cpp.config.example b/llama.cpp.config.example index b411535..4656f45 100644 --- a/llama.cpp.config.example +++ b/llama.cpp.config.example @@ -88,6 +88,24 @@ host_port = 8003 gpu_device = 1 ctx_size = 131072 +# Vision profile: `mmproj` points at the multimodal projector (vision encoder) +# GGUF. Like `model_path` it is resolved relative to `hf_home` unless absolute. +# The projector MUST match the base model's vision tower -- a projector built for +# a different base will not load. A missing projector aborts with exit code 3 +# (for --change: before the running container is removed). +# +# llama.cpp offloads the projector to the GPU by default; set mmproj_offload to +# false to keep it on the CPU when VRAM is tight. Images are then sent to +# /v1/chat/completions as an image_url content part -- `llamacppctl --chat` +# itself only sends text. +[model.vision] +model_path = qwen3/Qwen3-35B-A3B-Q4_K_M.gguf +mmproj = qwen3/mmproj.gguf +mmproj_offload = true +container_name = llama_cpp_vision +host_port = 8004 +gpu_device = 1 + [prompt.concise] system_prompt = Du antwortest kurz, präzise und technisch. diff --git a/src/llamacppctl/actions.py b/src/llamacppctl/actions.py index 9011338..f1bb902 100644 --- a/src/llamacppctl/actions.py +++ b/src/llamacppctl/actions.py @@ -77,15 +77,23 @@ def check_exit_code(result: CheckResult) -> int: return EXIT_HTTP_NOT_READY +def _host_path(cfg: ServerConfig, path_str: str) -> Path: + path = Path(path_str) + return path if path.is_absolute() else cfg.hf_home / path_str + + def validate_model_path(cfg: ServerConfig) -> None: - model_path = Path(cfg.model_path) - if model_path.is_absolute(): - target = model_path - else: - target = cfg.hf_home / cfg.model_path + """Check that the model file — and the vision projector, when one is + configured — exist on the host before the container is (re)created.""" + target = _host_path(cfg, cfg.model_path) if not target.exists(): raise FileNotFoundError(f"model file not found: {target}") + if cfg.mmproj: + projector = _host_path(cfg, cfg.mmproj) + if not projector.exists(): + raise FileNotFoundError(f"mmproj file not found: {projector}") + def print_effective_config(cfg: ServerConfig, prompt_cfg: PromptConfig) -> None: payload = { diff --git a/src/llamacppctl/cli.py b/src/llamacppctl/cli.py index 11ed47f..7230f42 100644 --- a/src/llamacppctl/cli.py +++ b/src/llamacppctl/cli.py @@ -46,6 +46,18 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--image", help="Docker image (e.g. ghcr.io/ggml-org/llama.cpp:server-cuda)") p.add_argument("--hf-home", help="Host path mounted read-only as model storage") p.add_argument("--model-path", help="Model path, relative to --hf-home unless absolute") + p.add_argument( + "--mmproj", + help="Vision projector (mmproj) GGUF, relative to --hf-home unless absolute. " + "Must match the base model's vision tower; enables image input", + ) + p.add_argument( + "--no-mmproj-offload", + dest="mmproj_offload", + action="store_false", + default=None, + help="Keep the vision projector on the CPU instead of offloading it to the GPU", + ) p.add_argument("--container-name", help="Docker container name (must be unique per config)") p.add_argument("--host-port", type=int, help="Host port to publish") p.add_argument("--container-port", type=int, help="Container-internal port") @@ -185,3 +197,6 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> if args.container_name is not None and not args.container_name.strip(): parser.error("--container-name must not be empty") + + if args.mmproj is not None and not args.mmproj.strip(): + parser.error("--mmproj must not be empty") diff --git a/src/llamacppctl/config.py b/src/llamacppctl/config.py index 684a6e9..331d899 100644 --- a/src/llamacppctl/config.py +++ b/src/llamacppctl/config.py @@ -71,6 +71,8 @@ def builtin_defaults() -> dict: "host": "0.0.0.0", "expose": "false", # publish only on loopback unless true "api_key": "", + "mmproj": "", # vision projector GGUF; empty => text-only server + "mmproj_offload": "true", "health_endpoint": "/health", "models_endpoint": "/v1/models", "chat_endpoint": "/v1/chat/completions", @@ -137,6 +139,8 @@ def _apply_cli_overrides(merged: dict, args) -> None: "lock_file": getattr(args, "lock_file", None), "expose": getattr(args, "expose", None), "api_key": getattr(args, "api_key", None), + "mmproj": getattr(args, "mmproj", None), + "mmproj_offload": getattr(args, "mmproj_offload", None), } for key, value in overrides.items(): if value is not None: @@ -193,6 +197,8 @@ def build_server_config(merged: dict) -> ServerConfig: host=str(merged["host"]), expose=_to_bool(merged.get("expose", "false"), "expose"), api_key=str(merged.get("api_key", "")), + mmproj=str(merged.get("mmproj") or "").strip(), + mmproj_offload=_to_bool(merged.get("mmproj_offload", "true"), "mmproj_offload"), health_endpoint=str(merged["health_endpoint"]), models_endpoint=str(merged["models_endpoint"]), chat_endpoint=str(merged["chat_endpoint"]), diff --git a/src/llamacppctl/docker_ops.py b/src/llamacppctl/docker_ops.py index 40c0669..d4184a3 100644 --- a/src/llamacppctl/docker_ops.py +++ b/src/llamacppctl/docker_ops.py @@ -105,11 +105,21 @@ def container_logs(name: str, tail: int = 100) -> str: tail_logs = container_logs +def _resolve_hf_path_in_container(path_str: str) -> str: + """Map a host-side model/projector path to its path inside the container. + Absolute paths are passed through; relative ones resolve under the + read-only /hf_home mount.""" + if Path(path_str).is_absolute(): + return path_str + return f"/hf_home/{path_str}" + + def _resolve_model_path_in_container(cfg: ServerConfig) -> str: - model_path = Path(cfg.model_path) - if model_path.is_absolute(): - return str(model_path) - return f"/hf_home/{cfg.model_path}" + return _resolve_hf_path_in_container(cfg.model_path) + + +def _resolve_mmproj_path_in_container(cfg: ServerConfig) -> str: + return _resolve_hf_path_in_container(cfg.mmproj) def _port_publish(cfg: ServerConfig) -> str: @@ -181,6 +191,10 @@ def build_run_command(cfg: ServerConfig) -> list: cmd.append("--kv-unified") if cfg.cont_batching: cmd.append("--cont-batching") + if cfg.mmproj: + cmd += ["--mmproj", _resolve_mmproj_path_in_container(cfg)] + if not cfg.mmproj_offload: + cmd.append("--no-mmproj-offload") if cfg.api_key: cmd += ["--api-key", cfg.api_key] cmd.extend(cfg.extra_args) diff --git a/src/llamacppctl/schema.py b/src/llamacppctl/schema.py index 13018c0..fab9b75 100644 --- a/src/llamacppctl/schema.py +++ b/src/llamacppctl/schema.py @@ -60,6 +60,14 @@ class ServerConfig: # as a Bearer token on every request. api_key: str = "" + # Optional multimodal projector (vision encoder) GGUF. Resolved relative to + # hf_home unless absolute, like model_path. Must match the base model's + # vision tower. Empty => text-only server. + mmproj: str = "" + # llama.cpp offloads the projector to the GPU by default; set False to keep + # it on the CPU when VRAM is tight (emits --no-mmproj-offload). + mmproj_offload: bool = True + extra_args: list = field(default_factory=list) diff --git a/tests/test_actions.py b/tests/test_actions.py index 3683444..8a881cc 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -213,3 +213,34 @@ def test_container_lock_force_bypasses_busy(tmp_path, capsys): entered = True assert entered is True assert "busy lock" in capsys.readouterr().err + + +# --- model / mmproj path validation --------------------------------------- + + +def test_validate_model_path_accepts_existing_model(tmp_path): + (tmp_path / "m.gguf").write_bytes(b"x") + cfg = make_cfg(hf_home=tmp_path, model_path="m.gguf") + actions.validate_model_path(cfg) # must not raise + + +def test_validate_model_path_rejects_missing_model(tmp_path): + cfg = make_cfg(hf_home=tmp_path, model_path="absent.gguf") + with pytest.raises(FileNotFoundError, match="model file not found"): + actions.validate_model_path(cfg) + + +def test_validate_model_path_rejects_missing_mmproj(tmp_path): + # The model exists but the configured projector does not: --change must fail + # here, before the running container is removed. + (tmp_path / "m.gguf").write_bytes(b"x") + cfg = make_cfg(hf_home=tmp_path, model_path="m.gguf", mmproj="absent-proj.gguf") + with pytest.raises(FileNotFoundError, match="mmproj file not found"): + actions.validate_model_path(cfg) + + +def test_validate_model_path_accepts_existing_mmproj(tmp_path): + (tmp_path / "m.gguf").write_bytes(b"x") + (tmp_path / "proj.gguf").write_bytes(b"x") + cfg = make_cfg(hf_home=tmp_path, model_path="m.gguf", mmproj="proj.gguf") + actions.validate_model_path(cfg) # must not raise diff --git a/tests/test_config.py b/tests/test_config.py index 38ddb2b..65f2785 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -55,6 +55,10 @@ model_alias = alt_llm [model.noname] host_port = 9002 +[model.vision] +container_name = test_vision +mmproj = qwen3/mmproj.gguf + [prompt.concise] system_prompt = Be brief. """ @@ -280,6 +284,57 @@ def test_stream_and_read_timeout_from_config(tmp_path): assert prompt_cfg.connect_timeout == 5.0 +def test_mmproj_empty_by_default(tmp_path): + cfg_path = _write_config(tmp_path) + server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) + assert server_cfg.mmproj == "" + assert server_cfg.mmproj_offload is True # llama.cpp offloads by default + + +def test_mmproj_from_profile(tmp_path): + cfg_path = _write_config(tmp_path) + args = _parse(["--start", "--config", str(cfg_path), "--profile", "vision"]) + server_cfg, _ = resolve_effective_config(args) + assert server_cfg.mmproj == "qwen3/mmproj.gguf" + assert server_cfg.container_name == "test_vision" + + +def test_mmproj_cli_overrides_config(tmp_path): + cfg_path = _write_config(tmp_path) + args = _parse( + [ + "--start", + "--config", + str(cfg_path), + "--profile", + "vision", + "--mmproj", + "other/proj.gguf", + "--no-mmproj-offload", + ] + ) + server_cfg, _ = resolve_effective_config(args) + assert server_cfg.mmproj == "other/proj.gguf" + assert server_cfg.mmproj_offload is False + + +def test_mmproj_offload_false_from_config(tmp_path): + cfg = CONFIG_BASIC.replace( + "[model.vision]\ncontainer_name = test_vision", + "[model.vision]\ncontainer_name = test_vision\nmmproj_offload = false", + ) + cfg_path = _write_config(tmp_path, cfg) + args = _parse(["--start", "--config", str(cfg_path), "--profile", "vision"]) + server_cfg, _ = resolve_effective_config(args) + assert server_cfg.mmproj_offload is False + + +def test_blank_mmproj_via_cli_rejected(tmp_path): + cfg_path = _write_config(tmp_path) + with pytest.raises(SystemExit): + _parse(["--start", "--config", str(cfg_path), "--mmproj", " "]) + + def test_stream_and_read_timeout_cli_overrides_config(tmp_path): cfg = CONFIG_BASIC.replace("poll_interval = 2", "poll_interval = 2\nread_timeout = 900") cfg_path = _write_config(tmp_path, cfg) diff --git a/tests/test_docker_ops.py b/tests/test_docker_ops.py index 75a6c33..6c1a7b2 100644 --- a/tests/test_docker_ops.py +++ b/tests/test_docker_ops.py @@ -178,6 +178,41 @@ def test_build_run_command_relative_model_path(): assert cmd[idx + 1] == "/hf_home/qwen3/default.gguf" +def test_build_run_command_no_mmproj_by_default(): + cmd = build_run_command(make_cfg()) + assert "--mmproj" not in cmd + assert "--no-mmproj-offload" not in cmd + + +def test_build_run_command_mmproj_relative_path_resolves_under_hf_home(): + cfg = make_cfg(mmproj="qwen3/mmproj.gguf") + cmd = build_run_command(cfg) + idx = cmd.index("--mmproj") + assert cmd[idx + 1] == "/hf_home/qwen3/mmproj.gguf" + # offload is llama.cpp's default, so the opt-out flag must stay absent + assert "--no-mmproj-offload" not in cmd + + +def test_build_run_command_mmproj_absolute_path_passed_through(): + cfg = make_cfg(mmproj="/abs/mmproj.gguf") + cmd = build_run_command(cfg) + idx = cmd.index("--mmproj") + assert cmd[idx + 1] == "/abs/mmproj.gguf" + + +def test_build_run_command_mmproj_offload_disabled(): + cfg = make_cfg(mmproj="qwen3/mmproj.gguf", mmproj_offload=False) + cmd = build_run_command(cfg) + assert "--no-mmproj-offload" in cmd + + +def test_build_run_command_no_mmproj_offload_needs_mmproj(): + # Without a projector the offload opt-out is meaningless and must not leak + # into the command line (llama.cpp would reject it). + cfg = make_cfg(mmproj="", mmproj_offload=False) + assert "--no-mmproj-offload" not in build_run_command(cfg) + + def test_format_command_for_display_quotes_properly(): cmd = ["docker", "run", "--name", "has space"] out = format_command_for_display(cmd) From 3e3dd1c15045e99fffd91ce0a46254df76e98c28 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 10 Jul 2026 16:25:07 +0200 Subject: [PATCH 14/18] fix(cli): stop --print-effective-config from starting a container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argparse requires exactly one action, so the documented diagnostic form `--print-effective-config --config … --start` never took the early-return branch in main.run(): it printed the resolved config and then executed a real do_start(), silently replacing a running container with the [default] model. README, installation guide and manual all recommended that form. Make it an action in the mutually exclusive group. It can no longer be combined with --start/--check/--stop/--change/--chat (argparse error, exit 2), and it skips the docker_available() check, so it now really is the offline config check the docs promise. --dry-run remains a modifier and still requires a reachable daemon. Co-Authored-By: Claude Opus 4.8 --- README.md | 11 ++++--- docs/BEDIENUNGSANLEITUNG.md | 2 +- docs/INSTALL_FROM_ARCHIVE.md | 2 +- docs/SECURITY_AND_OPERATIONS.md | 27 ++++++++++------ src/llamacppctl/cli.py | 13 +++++--- src/llamacppctl/main.py | 11 +++---- tests/test_cli.py | 11 +++++-- tests/test_main.py | 57 +++++++++++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 30 deletions(-) create mode 100644 tests/test_main.py diff --git a/README.md b/README.md index a780db4..197c931 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,10 @@ Voraussetzungen auf dem Zielsystem: - Docker (CLI + laufender Daemon), für `--start`/--check`/--stop`/--change` - Netzwerkzugriff auf den Container-Host-Port für `--chat`/--check` -`--print-effective-config` und `--dry-run` benötigen keinen laufenden Docker-Daemon -für die reine Konfigurationsprüfung; `--start`/--check`/--stop`/--change` erfordern -einen erreichbaren Docker-Daemon. +`--print-effective-config` ist eine eigenständige, nebenwirkungsfreie Aktion und +benötigt keinen laufenden Docker-Daemon. `--start`/--check`/--stop`/--change` +erfordern einen erreichbaren Docker-Daemon — auch zusammen mit `--dry-run`, weil +die Verfügbarkeit geprüft wird, bevor der Trockenlauf greift. ## Konfiguration @@ -93,8 +94,8 @@ llamacppctl --start --config llama.cpp.config # Nur den geplanten docker-run-Befehl anzeigen, nichts ausführen llamacppctl --start --config llama.cpp.config --dry-run -# Effektive Konfiguration als JSON ausgeben -llamacppctl --print-effective-config --config llama.cpp.config --start +# Effektive Konfiguration als JSON ausgeben (startet nichts, braucht kein Docker) +llamacppctl --print-effective-config --config llama.cpp.config # Status prüfen llamacppctl --check --config llama.cpp.config diff --git a/docs/BEDIENUNGSANLEITUNG.md b/docs/BEDIENUNGSANLEITUNG.md index 87b2605..7b8a91c 100644 --- a/docs/BEDIENUNGSANLEITUNG.md +++ b/docs/BEDIENUNGSANLEITUNG.md @@ -49,7 +49,7 @@ Siehe [`INSTALL_FROM_ARCHIVE.md`](INSTALL_FROM_ARCHIVE.md). ```bash llamacppctl --help -llamacppctl --print-effective-config --config llama.cpp.config --start --dry-run +llamacppctl --print-effective-config --config llama.cpp.config ``` --- diff --git a/docs/INSTALL_FROM_ARCHIVE.md b/docs/INSTALL_FROM_ARCHIVE.md index 2c7c2ef..ab0e641 100644 --- a/docs/INSTALL_FROM_ARCHIVE.md +++ b/docs/INSTALL_FROM_ARCHIVE.md @@ -64,7 +64,7 @@ cp llama.cpp.config.example llama.cpp.config Auflösung prüfen, ohne etwas zu starten: ```bash -llamacppctl --print-effective-config --config llama.cpp.config --start --dry-run +llamacppctl --print-effective-config --config llama.cpp.config ``` Das zeigt die vollständig aufgelöste Konfiguration als JSON **und** das diff --git a/docs/SECURITY_AND_OPERATIONS.md b/docs/SECURITY_AND_OPERATIONS.md index 4eaa144..254d7d6 100644 --- a/docs/SECURITY_AND_OPERATIONS.md +++ b/docs/SECURITY_AND_OPERATIONS.md @@ -258,22 +258,29 @@ keinen vermeidbaren Ausfall verursacht. ## 6. `--dry-run` und `--print-effective-config` als Sicherheitswerkzeuge -- `--print-effective-config` gibt die vollständig aufgelöste Konfiguration - (Server- und Prompt-Konfiguration) als JSON aus, **bevor** irgendeine - Docker- oder HTTP-Aktion ausgeführt wird. Damit lässt sich prüfen, welche - Werte aus welcher Quelle (Defaults/[default]/[model.*]/CLI) tatsächlich - gewonnen haben, ohne einen Container anzufassen. +- `--print-effective-config` ist eine **eigene Aktion** in der + Mutually-Exclusive-Gruppe: sie gibt die vollständig aufgelöste Konfiguration + (Server- und Prompt-Konfiguration) als JSON aus und beendet sich. Damit lässt + sich prüfen, welche Werte aus welcher Quelle (Defaults/[default]/[model.*]/CLI) + tatsächlich gewonnen haben, ohne einen Container anzufassen. + + Bis einschließlich 0.1.0 war dies ein *Flag*. Da argparse immer eine Aktion + verlangt, führte `--print-effective-config --start` die Konfigurationsausgabe + **und anschließend einen echten `do_start()`** aus — der einen laufenden + Container stillschweigend ersetzte. Die Kombination ist jetzt ein + argparse-Fehler (Exit-Code 2). - `--dry-run` (in Kombination mit `--start`/--change`) zeigt den vollständig zusammengesetzten `docker run`-Befehl (Shell-quotiert zur Anzeige) an, **ohne** ihn auszuführen. Empfohlen vor jeder Änderung an einer Produktionskonfiguration, insbesondere nach Anpassungen an `llama.cpp.config`. -Beide Flags erfordern **keinen** erreichbaren Docker-Daemon für ihre reine -Ausgabe — `main.run()` prüft `docker_available()` derzeit vor der -Konfigurationsauflösung; auf einem Host ganz ohne Docker (z. B. zur reinen -Konfigurationsvalidierung) schlägt der Aufruf entsprechend mit einer -expliziten Fehlermeldung fehl statt still falsche Annahmen zu treffen. +`--print-effective-config` erfordert **keinen** erreichbaren Docker-Daemon; +`main.run()` überspringt die `docker_available()`-Prüfung für diese Aktion. Für +`--dry-run` gilt das **nicht**: es ist ein Modifikator von `--start`/`--change`, +und die Docker-Prüfung läuft vor der Konfigurationsauflösung. Auf einem Host ganz +ohne Docker schlägt `--start --dry-run` daher mit einer expliziten Fehlermeldung +fehl, statt still falsche Annahmen zu treffen. ## 7. Betriebsmodell auf einem dedizierten GPU-Host diff --git a/src/llamacppctl/cli.py b/src/llamacppctl/cli.py index 7230f42..d32da57 100644 --- a/src/llamacppctl/cli.py +++ b/src/llamacppctl/cli.py @@ -20,6 +20,14 @@ def build_parser() -> argparse.ArgumentParser: action.add_argument("--stop", action="store_true", help="Stop/remove the container") action.add_argument("--change", action="store_true", help="Stop, reconfigure, and restart") action.add_argument("--chat", action="store_true", help="Send a prompt to a running server") + # A diagnostic action, not a modifier: combining it with --start used to print + # the config and then really start the container. It is mutually exclusive with + # the other actions so it can never have a side effect. + action.add_argument( + "--print-effective-config", + action="store_true", + help="Print the fully merged configuration as JSON and exit. Touches nothing", + ) p.add_argument("--config", default="llama.cpp.config", help="Path to llama.cpp.config") p.add_argument("--profile", help="Model profile name: [model.] in config") @@ -146,11 +154,6 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--log-lines", type=int, default=100) p.add_argument("--dry-run", action="store_true", help="Show the docker run command, do not execute it") p.add_argument("--force", action="store_true", help="Force stop/change despite inconsistencies") - p.add_argument( - "--print-effective-config", - action="store_true", - help="Print the fully merged configuration as JSON and exit (unless combined with an action)", - ) return p diff --git a/src/llamacppctl/main.py b/src/llamacppctl/main.py index b633b96..0c82d56 100644 --- a/src/llamacppctl/main.py +++ b/src/llamacppctl/main.py @@ -3,7 +3,7 @@ Orchestration order: 1. Parse CLI arguments (cli.build_parser) 2. Run semantic validation (cli.validate_args) - 3. Check that docker is available + 3. Check that docker is available (skipped for --print-effective-config) 4. Resolve prompt sources (prompt_io) under an InputPolicy built from CLI flags 5. Resolve effective server/prompt config (config.resolve_effective_config) 6. Dispatch to the requested action (actions.do_*) @@ -55,7 +55,9 @@ def run(argv: Optional[Sequence[str]] = None) -> int: args = parser.parse_args(argv) validate_args(args, parser) - if not docker_available(): + # --print-effective-config is purely diagnostic: it must work on a host + # without a Docker daemon, and it must never touch a container. + if not args.print_effective_config and not docker_available(): print("docker is not available on PATH (or the daemon is not reachable)", file=sys.stderr) return 1 @@ -74,10 +76,7 @@ def run(argv: Optional[Sequence[str]] = None) -> int: if args.print_effective_config: actions.print_effective_config(server_cfg, prompt_cfg) - # An action is always required by argparse; only exit early if the run - # is purely diagnostic (no action would actually do anything). - if not (args.start or args.check or args.stop or args.change or args.chat): - return 0 + return 0 if args.start: return actions.do_start(server_cfg, prompt_cfg, args) diff --git a/tests/test_cli.py b/tests/test_cli.py index 051035c..b792e41 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -147,9 +147,16 @@ def test_dry_run_flag(): assert args.dry_run is True -def test_print_effective_config_flag(): - args = parse(["--start", "--print-effective-config"]) +def test_print_effective_config_is_a_standalone_action(): + args = parse(["--print-effective-config"]) assert args.print_effective_config is True + assert args.start is False + + +def test_print_effective_config_cannot_be_combined_with_start(): + # Regression: as a plain flag this printed the config and then really started + # the container, silently replacing a running one. + parse_error(["--print-effective-config", "--start"]) def test_reasoning_choice_validated(): diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..6b1e554 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,57 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from llamacppctl import actions, main as main_mod # noqa: E402 + +CONFIG = """ +[default] +hf_home = /srv/models +model_path = qwen3/default.gguf +container_name = test_main +""" + + +def _config(tmp_path: Path) -> Path: + path = tmp_path / "llama.cpp.config" + path.write_text(CONFIG, encoding="utf-8") + return path + + +def test_print_effective_config_has_no_side_effects(tmp_path, monkeypatch, capsys): + """Regression: --print-effective-config used to fall through into do_start() + because argparse always required an action, silently replacing a running + container.""" + started = [] + monkeypatch.setattr(actions, "do_start", lambda *a, **k: started.append(1)) + monkeypatch.setattr(actions, "do_change", lambda *a, **k: started.append(1)) + monkeypatch.setattr(actions, "do_stop", lambda *a, **k: started.append(1)) + + rc = main_mod.run(["--print-effective-config", "--config", str(_config(tmp_path))]) + + assert rc == 0 + assert started == [] + assert '"container_name": "test_main"' in capsys.readouterr().out + + +def test_print_effective_config_works_without_docker(tmp_path, monkeypatch, capsys): + """It is a pure config check, so it must not require a reachable daemon.""" + def boom() -> bool: + raise AssertionError("docker_available() must not be consulted") + + monkeypatch.setattr(main_mod, "docker_available", boom) + + rc = main_mod.run(["--print-effective-config", "--config", str(_config(tmp_path))]) + + assert rc == 0 + assert '"hf_home": "/srv/models"' in capsys.readouterr().out + + +def test_start_still_requires_docker(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(main_mod, "docker_available", lambda: False) + + rc = main_mod.run(["--start", "--config", str(_config(tmp_path))]) + + assert rc == 1 + assert "docker is not available" in capsys.readouterr().err From 6f2f8aff6ca27a763d9320666f676fc7bf59288c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 10 Jul 2026 16:25:26 +0200 Subject: [PATCH 15/18] feat(scripts): add prompt-test evaluator and suite runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eval_prompt_tests.py measures the objective half of docs/EVAL_RUBRIC.md over the manual test archive: word count against the target stated in each prompt, truncation suspicion, and — for the coding domain — it writes the generated module and tests to a temp dir and actually runs pytest against them. Deriving the module's filename is the delicate part: a name taken from a test's `import sqlite3` would shadow the stdlib and fail the run for a reason the model is not responsible for. Names now come from the last *.py mention before the block, then from `from X import`, and anything in sys.stdlib_module_names is rejected. A module that no test imports is reported as such, since that is a finding about test quality rather than a guess the runner got wrong. run_prompt_suite.sh drives one prompt domain against a running profile and stores the outputs under the archive's naming convention. Both scripts join the ruff gate. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/ci.yml | 2 +- scripts/check.sh | 2 +- scripts/eval_prompt_tests.py | 457 +++++++++++++++++++++++++++++++++++ scripts/run_prompt_suite.sh | 97 ++++++++ 4 files changed, 556 insertions(+), 2 deletions(-) create mode 100755 scripts/eval_prompt_tests.py create mode 100755 scripts/run_prompt_suite.sh diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8041541..c931ba7 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: run: pip install -e ".[dev]" - name: Ruff (lint) - run: ruff check src/ tests/ build_archive.py + run: ruff check src/ tests/ build_archive.py scripts/eval_prompt_tests.py - name: Mypy (type check) run: mypy diff --git a/scripts/check.sh b/scripts/check.sh index a7e59c7..9773ca7 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -27,7 +27,7 @@ run() { fi } -run "ruff (lint)" "${BIN}ruff" check src/ tests/ build_archive.py +run "ruff (lint)" "${BIN}ruff" check src/ tests/ build_archive.py scripts/eval_prompt_tests.py run "mypy (types)" "${BIN}mypy" # Use `python -m pytest` (not the pytest console script) so the repo root is on # sys.path — the test modules import `from tests.test_docker_ops import ...`. diff --git a/scripts/eval_prompt_tests.py b/scripts/eval_prompt_tests.py new file mode 100755 index 0000000..290db3d --- /dev/null +++ b/scripts/eval_prompt_tests.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""eval_prompt_tests.py + +Auswertung der manuellen Modell-Prompt-Testlaeufe unter +``~/llamacppctl_prompt_tests/`` gegen die Prompts in ``example_user_prompts/``. + +Misst die objektive Ebene der Rubrik (docs/EVAL_RUBRIC.md): + + * prosa/reden: Wortzahl und Abweichung vom im Prompt genannten Zielwert, + Trunkierungsverdacht. + * coding: extrahiert die Python-Codebloecke, schreibt Modul und Tests in + ein temporaeres Verzeichnis und **fuehrt pytest tatsaechlich aus**. + +WARNUNG: ``--exec`` fuehrt modellgenerierten Code aus. Der Runner nutzt ein +temporaeres Verzeichnis, ein Zeitlimit und verweigert den Start als root, bietet +aber keine Netz- oder Dateisystem-Isolation. Siehe docs/EVAL_RUBRIC.md. + +Beispiele +--------- + scripts/eval_prompt_tests.py # messen + Tests ausfuehren + scripts/eval_prompt_tests.py --no-exec # nur statisch messen + scripts/eval_prompt_tests.py --json report.json # maschinenlesbar + scripts/eval_prompt_tests.py --anonymize blind/ # blinde Bewertung vorbereiten + +Exit-Codes +---------- + 0 Auswertung durchgelaufen (auch wenn einzelne Testlaeufe rot sind) + 1 Eingabeverzeichnis fehlt oder unsichere Ausfuehrungsumgebung +""" + +from __future__ import annotations + +import argparse +import json +import os +import random +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Optional + +DEFAULT_RESULTS_DIR = Path.home() / "llamacppctl_prompt_tests" +DEFAULT_PROMPTS_DIR = Path(__file__).resolve().parents[1] / "example_user_prompts" + +DOMAINS = ("prosa", "reden", "coding") +# Dateien ohne Modellpraefix stammen vom Default-Modell aus llama.cpp.config. +KNOWN_MODELS = ("carnice", "qwen27b", "qwopus", "ornith", "qwen35base") +DEFAULT_MODEL_LABEL = "default" +VARIANT_MARKERS = frozenset({"r2", "highbudget", "explizit"}) + +# "etwa 1200 Wörtern", "ca. 700 Wörter", "rund 900 Worten" +LENGTH_TARGET_RE = re.compile( + r"(?:etwa|ca\.|circa|rund)\s*(\d{2,5})\s*(?:Wörter|Wörtern|Worte|Worten)", + re.IGNORECASE, +) +FENCE_RE = re.compile(r"^```([A-Za-z+#]*)\s*$(.*?)^```\s*$", re.MULTILINE | re.DOTALL) +PY_FILENAME_RE = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)\.py`") +FROM_IMPORT_RE = re.compile(r"^\s*from\s+([A-Za-z_][A-Za-z0-9_]*)\s+import", re.MULTILINE) +PLAIN_IMPORT_RE = re.compile(r"^\s*import\s+([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE) +PYTEST_COUNT_RE = re.compile(r"(\d+)\s+(passed|failed|error|errors)") + +# Endet der Text sauber? Fehlendes Satzzeichen deutet auf finish_reason=length. +SENTENCE_END = tuple('.!?"»“’—-)') + +# Ein Modul darf niemals nach einem Stdlib-Modul benannt werden: eine Datei +# sqlite3.py im Arbeitsverzeichnis ueberschattet die echte Stdlib und laesst den +# Testlauf mit einem Importfehler scheitern, der faelschlich dem Modell +# angelastet wuerde. sys.stdlib_module_names ist exakt (Python >= 3.10). +RESERVED_MODULE_NAMES = frozenset(sys.stdlib_module_names) | {"pytest", "conftest"} + + +@dataclass +class Run: + """Ein einzelner Testlauf, abgeleitet aus dem Dateinamen.""" + + path: Path + model: str + domain: str + case: str + variant: str = "" + + @property + def label(self) -> str: + base = f"{self.model}/{self.domain}_{self.case}" + return f"{base}[{self.variant}]" if self.variant else base + + +@dataclass +class Metrics: + label: str + model: str + domain: str + case: str + variant: str + words: int = 0 + target_words: Optional[int] = None + deviation_pct: Optional[float] = None + looks_truncated: bool = False + # coding + executed: bool = False + exec_status: str = "" # ok | failed | timeout | no-tests | skipped- + tests_passed: int = 0 + tests_failed: int = 0 + detail: str = "" + code_langs: list = field(default_factory=list) + + +def parse_run(path: Path) -> Optional[Run]: + """Zerlegt ``[_]_[_].out.txt``. + + Die Namenskonvention ist historisch uneinheitlich (mal steht der Fallname + hinter der Nummer, mal eine Variante wie ``r2``), deshalb wird tolerant + geparst statt streng validiert. + """ + stem = path.name + for suffix in (".out.txt", ".txt", ".md"): + if stem.endswith(suffix): + stem = stem[: -len(suffix)] + break + + tokens = stem.split("_") + if not tokens: + return None + + model = DEFAULT_MODEL_LABEL + if tokens[0] in KNOWN_MODELS: + model = tokens[0] + tokens = tokens[1:] + + domain = next((t for t in tokens if t in DOMAINS), None) + if domain is None: + return None + rest = tokens[tokens.index(domain) + 1 :] + + case = next((t for t in rest if t.isdigit()), None) + if case is None: + return None + + # Der Fallname (z. B. "jsonl_validator") ist keine Variante. Nur bekannte + # Marker zaehlen -- sie muessen erhalten bleiben, sonst kollidieren zwei + # verschiedene Laeufe (prosa_04_dialog vs. prosa_04_dialog_highbudget) unter + # demselben Label. + variant = "_".join(t for t in rest[rest.index(case) + 1 :] if t in VARIANT_MARKERS) + + return Run(path=path, model=model, domain=domain, case=case, variant=variant) + + +def load_length_targets(prompts_dir: Path) -> dict: + """Mappt (domaene, nr) auf die im Prompt genannte Ziel-Wortzahl.""" + targets: dict = {} + if not prompts_dir.is_dir(): + return targets + for prompt in sorted(prompts_dir.glob("*.md")): + tokens = prompt.stem.split("_") + if len(tokens) < 2 or tokens[0] not in DOMAINS or not tokens[1].isdigit(): + continue + match = LENGTH_TARGET_RE.search(prompt.read_text(encoding="utf-8")) + if match: + targets[(tokens[0], tokens[1])] = int(match.group(1)) + return targets + + +def count_words(text: str) -> int: + """Woerter ohne Markdown-Auszeichnung und ohne Codebloecke.""" + without_code = FENCE_RE.sub("", text) + cleaned = re.sub(r"[#*_>`]", " ", without_code) + return len(cleaned.split()) + + +def looks_truncated(text: str) -> bool: + stripped = text.strip() + if not stripped: + return True + return not stripped.endswith(SENTENCE_END) + + +def extract_code_blocks(text: str) -> list: + """Liefert (sprache, code, start_offset) je Fence-Block. + + Der Offset wird gebraucht, um den im Fliesstext *vor* einem Block genannten + Dateinamen zu finden; ein Textvergleich waere mehrdeutig, wenn derselbe Code + mehrfach vorkommt. + """ + return [ + (match.group(1).lower(), match.group(2), match.start()) + for match in FENCE_RE.finditer(text) + ] + + +def is_test_block(code: str) -> bool: + return "def test_" in code or "import pytest" in code + + +def _usable(name: str) -> bool: + return bool(name) and name not in RESERVED_MODULE_NAMES and not name.startswith("test_") + + +def infer_module_name(markdown: str, block_start: int, tests: list) -> str: + """Ermittelt den Dateinamen des Moduls. + + Reihenfolge: (1) letzte im Fliesstext *vor* dem Block genannte ``*.py``-Datei, + (2) ``from X import`` in den Tests -- die Tests importieren das Modul unter + genau diesem Namen, (3) ``import X``. Stdlib-Namen und ``test_*`` werden in + jeder Stufe verworfen. + """ + for name in reversed(PY_FILENAME_RE.findall(markdown[:block_start])): + if _usable(name): + return name + + for pattern in (FROM_IMPORT_RE, PLAIN_IMPORT_RE): + for test_code in tests: + for candidate in pattern.findall(test_code): + if _usable(candidate): + return candidate + + return "generated_module" + + +def _parse_pytest_counts(output: str) -> tuple: + passed = failed = 0 + for count, kind in PYTEST_COUNT_RE.findall(output): + if kind == "passed": + passed = int(count) + else: + failed += int(count) + return passed, failed + + +def run_python_case(markdown: str, timeout: int, keep_dir: Optional[Path]) -> dict: + """Schreibt Modul + Tests in ein Temp-Verzeichnis und ruft pytest auf.""" + blocks = extract_code_blocks(markdown) + py_blocks = [(body, start) for lang, body, start in blocks if lang in ("python", "py")] + if not py_blocks: + return {"exec_status": "no-code", "detail": "kein Python-Block gefunden"} + + tests = [body for body, _ in py_blocks if is_test_block(body)] + modules = [(body, start) for body, start in py_blocks if not is_test_block(body)] + if not tests: + return {"exec_status": "no-tests", "detail": f"{len(modules)} Modul-Block(loecke), keine Tests"} + + workdir = Path(tempfile.mkdtemp(prefix="llamaeval_")) + warnings = [] + try: + used: set = set() + for module_body, block_start in modules: + name = infer_module_name(markdown, block_start, tests) + if name == "generated_module": + # Weder der Fliesstext nennt einen Dateinamen noch importieren die + # Tests das Modul: ein echter Mangel der Testqualitaet, kein + # Ratefehler des Runners. + warnings.append("Tests importieren das Modul nicht") + while name in used: # zwei Modul-Bloecke, gleicher geratener Name + name += "_x" + used.add(name) + (workdir / f"{name}.py").write_text(module_body, encoding="utf-8") + for index, test_body in enumerate(tests): + suffix = "" if index == 0 else f"_{index}" + (workdir / f"test_generated{suffix}.py").write_text(test_body, encoding="utf-8") + + try: + proc = subprocess.run( + [sys.executable, "-m", "pytest", "-q", "--tb=no", "-p", "no:cacheprovider"], + cwd=workdir, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return {"exec_status": "timeout", "detail": f"pytest > {timeout}s"} + except FileNotFoundError: + return {"exec_status": "skipped-no-pytest", "detail": "pytest nicht gefunden"} + + output = proc.stdout + proc.stderr + passed, failed = _parse_pytest_counts(output) + status = "ok" if proc.returncode == 0 else "failed" + last = [ln for ln in output.strip().splitlines() if ln.strip()] + detail = "; ".join(warnings + ([last[-1]] if last else [])) + return { + "exec_status": status, + "tests_passed": passed, + "tests_failed": failed, + "detail": detail[:120], + } + finally: + if keep_dir is not None: + shutil.move(str(workdir), str(keep_dir / workdir.name)) + else: + shutil.rmtree(workdir, ignore_errors=True) + + +def evaluate(run: Run, targets: dict, do_exec: bool, timeout: int, keep_dir) -> Metrics: + text = run.path.read_text(encoding="utf-8", errors="replace") + metrics = Metrics( + label=run.label, + model=run.model, + domain=run.domain, + case=run.case, + variant=run.variant, + words=count_words(text), + looks_truncated=looks_truncated(text), + code_langs=sorted({lang for lang, _, _ in extract_code_blocks(text) if lang}), + ) + + target = targets.get((run.domain, run.case)) + if target: + metrics.target_words = target + metrics.deviation_pct = round((metrics.words - target) / target * 100, 1) + + if run.domain != "coding": + return metrics + + if not do_exec: + metrics.exec_status = "skipped-no-exec" + return metrics + if "typescript" in metrics.code_langs and "python" not in metrics.code_langs: + metrics.exec_status = "skipped-typescript" + metrics.detail = "kein tsc im PATH; TypeScript-Lauf nicht implementiert" + return metrics + + result = run_python_case(text, timeout, keep_dir) + metrics.executed = result["exec_status"] in ("ok", "failed") + metrics.exec_status = result["exec_status"] + metrics.tests_passed = result.get("tests_passed", 0) + metrics.tests_failed = result.get("tests_failed", 0) + metrics.detail = result.get("detail", "") + return metrics + + +def anonymize(runs: list, out_dir: Path) -> Path: + """Kopiert die Laeufe unter Zufalls-IDs und schreibt die Aufloesungstabelle. + + Fuer blinde Bewertung: der Bewertende darf das Modell nicht kennen. + """ + out_dir.mkdir(parents=True, exist_ok=True) + ids = [f"text_{i:03d}" for i in range(len(runs))] + random.shuffle(ids) + mapping = {} + for run, blind_id in zip(runs, ids): + shutil.copyfile(run.path, out_dir / f"{blind_id}.txt") + mapping[blind_id] = {"model": run.model, "domain": run.domain, "case": run.case, + "variant": run.variant, "source": run.path.name} + key_path = out_dir / "AUFLOESUNG.json" + key_path.write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding="utf-8") + return key_path + + +def _fmt_dev(metrics: Metrics) -> str: + if metrics.deviation_pct is None: + return "—" + return f"{metrics.deviation_pct:+.1f}%" + + +def print_report(all_metrics: list) -> None: + for domain in DOMAINS: + rows = [m for m in all_metrics if m.domain == domain] + if not rows: + continue + print(f"\n=== {domain} ===") + if domain == "coding": + print(f"{'Lauf':<28} {'Status':<18} {'passed':>6} {'failed':>6} Detail") + for m in sorted(rows, key=lambda r: (r.model, r.case)): + print(f"{m.label:<28} {m.exec_status:<18} {m.tests_passed:>6} " + f"{m.tests_failed:>6} {m.detail[:60]}") + else: + print(f"{'Lauf':<28} {'Wörter':>7} {'Ziel':>6} {'Abw.':>8} {'trunkiert':<9}") + for m in sorted(rows, key=lambda r: (r.model, r.case)): + target = str(m.target_words) if m.target_words else "—" + trunc = "ja" if m.looks_truncated else "" + print(f"{m.label:<28} {m.words:>7} {target:>6} {_fmt_dev(m):>8} {trunc:<9}") + + coding = [m for m in all_metrics if m.domain == "coding" and m.executed] + if coding: + green = sum(1 for m in coding if m.exec_status == "ok") + total_p = sum(m.tests_passed for m in coding) + total_f = sum(m.tests_failed for m in coding) + print(f"\nCoding gesamt: {green}/{len(coding)} Läufe grün, " + f"{total_p} Tests bestanden, {total_f} durchgefallen.") + + lengths = [m for m in all_metrics if m.deviation_pct is not None] + if lengths: + within5 = sum(1 for m in lengths if abs(m.deviation_pct) <= 5) + worst = max(lengths, key=lambda m: abs(m.deviation_pct)) + print(f"Längentreue: {within5}/{len(lengths)} Läufe im ±5%-Fenster; " + f"größte Abweichung {_fmt_dev(worst)} ({worst.label}).") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1] if __doc__ else "") + parser.add_argument("--results-dir", type=Path, default=DEFAULT_RESULTS_DIR) + parser.add_argument("--prompts-dir", type=Path, default=DEFAULT_PROMPTS_DIR) + parser.add_argument("--no-exec", dest="exec_code", action="store_false", default=True, + help="Generierten Code NICHT ausführen, nur statisch messen") + parser.add_argument("--timeout", type=int, default=60, help="Sekunden pro pytest-Lauf") + parser.add_argument("--json", type=Path, help="Report zusätzlich als JSON schreiben") + parser.add_argument("--keep-workdirs", type=Path, + help="Temp-Verzeichnisse der Testläufe hierhin retten (Debugging)") + parser.add_argument("--anonymize", type=Path, + help="Anonymisierte Kopien + Auflösungstabelle für blinde Bewertung") + args = parser.parse_args() + + if not args.results_dir.is_dir(): + print(f"Ergebnisverzeichnis nicht gefunden: {args.results_dir}", file=sys.stderr) + return 1 + + if args.exec_code and hasattr(os, "geteuid") and os.geteuid() == 0: + print("Verweigert: modellgenerierten Code nicht als root ausführen " + "(--no-exec erzwingt die statische Auswertung).", file=sys.stderr) + return 1 + + runs = [] + skipped = [] + for path in sorted(args.results_dir.iterdir()): + if not path.is_file(): + continue + run = parse_run(path) + (runs.append(run) if run else skipped.append(path.name)) + + if not runs: + print(f"Keine auswertbaren Läufe in {args.results_dir}", file=sys.stderr) + return 1 + + if args.anonymize: + key = anonymize(runs, args.anonymize) + print(f"{len(runs)} Texte anonymisiert nach {args.anonymize}; Auflösung: {key}") + + if args.keep_workdirs: + args.keep_workdirs.mkdir(parents=True, exist_ok=True) + + targets = load_length_targets(args.prompts_dir) + if not targets: + print(f"Warnung: keine Ziel-Wortzahlen aus {args.prompts_dir} gelesen", file=sys.stderr) + + all_metrics = [ + evaluate(run, targets, args.exec_code, args.timeout, args.keep_workdirs) + for run in runs + ] + + print_report(all_metrics) + if skipped: + print(f"\nÜbersprungen ({len(skipped)}): {', '.join(skipped)}") + + if args.json: + args.json.write_text( + json.dumps([asdict(m) for m in all_metrics], indent=2, ensure_ascii=False), + encoding="utf-8", + ) + print(f"JSON-Report: {args.json}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run_prompt_suite.sh b/scripts/run_prompt_suite.sh new file mode 100755 index 0000000..bc73570 --- /dev/null +++ b/scripts/run_prompt_suite.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Faehrt eine Domaene der Beispiel-Prompts gegen ein laufendes Modell-Profil und +# legt die Ausgaben unter der etablierten Namenskonvention im Test-Archiv ab: +# +#