Compare commits
4 commits
489af21fd2
...
bee6a71de0
| Author | SHA1 | Date | |
|---|---|---|---|
| bee6a71de0 | |||
| 6f2f8aff6c | |||
| 3e3dd1c150 | |||
| 218c3fc791 |
24 changed files with 1265 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -11,11 +11,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Per-profile chat streaming and chat-request timeouts: `stream`, `read_timeout`
|
||||
and `connect_timeout` are now resolvable in `[default]`/`[model.<profile>]`
|
||||
(CLI `--stream`/`--read-timeout`/`--connect-timeout` still override).
|
||||
- Multimodal support: `mmproj` and `mmproj_offload` config keys (CLI `--mmproj`,
|
||||
`--no-mmproj-offload`) pass a vision projector to the server. The path resolves
|
||||
under `hf_home` like `model_path`, and a configured-but-missing projector now
|
||||
fails with exit code 3 — for `--change` before the running container is removed.
|
||||
- `docs/KI_TOOLS_PROFILES.md`: which local model suits which task, plus mmproj
|
||||
compatibility per base model.
|
||||
- `docs/EVAL_RUBRIC.md` and `scripts/eval_prompt_tests.py`: scoring rubric and an
|
||||
evaluator for the manual prompt-test archive that measures length compliance and
|
||||
**executes** the model-generated code against its own pytest suite.
|
||||
- `scripts/run_prompt_suite.sh`: runs a prompt domain against a running profile and
|
||||
stores the outputs under the archive's naming convention.
|
||||
- `[model.qwen35base]` profile: the non-abliterated Qwen3.6-35B-A3B (bartowski
|
||||
imatrix Q4_K_M) as a control for the abliteration comparison. Inherits sampling,
|
||||
`ctx_size` and cache types from `[default]`; only model, container, port and GPU
|
||||
differ, so it runs alongside the production server.
|
||||
|
||||
### 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.
|
||||
- **Breaking:** `--print-effective-config` is now an action in the mutually
|
||||
exclusive action group instead of a flag, and it skips the `docker_available()`
|
||||
check. `--print-effective-config --start` is therefore an argparse error
|
||||
(exit 2) rather than a config dump followed by a real container start.
|
||||
|
||||
### Fixed
|
||||
- `--print-effective-config` combined with an action silently performed that
|
||||
action. Documented as a diagnostic command (README, installation guide, manual),
|
||||
`--print-effective-config --config … --start` printed the resolved config and
|
||||
then ran `do_start()`, replacing a running container with the `[default]` model.
|
||||
|
||||
## [0.1.0] - 2026-07-07
|
||||
|
||||
|
|
|
|||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]" # dev install
|
||||
scripts/check.sh # full local CI gate (ruff + mypy + pytest) — mirrors .forgejo/workflows/ci.yml
|
||||
python -m pytest -q # tests
|
||||
python -m pytest tests/test_prompt_urls.py -q # one file
|
||||
python -m pytest tests/test_actions.py::test_do_start -q # one test
|
||||
ruff check src/ tests/ build_archive.py
|
||||
mypy # config in pyproject.toml (files = src/llamacppctl)
|
||||
```
|
||||
|
||||
Always use `python -m pytest`, never the `pytest` console script: the test modules do
|
||||
`from tests.test_docker_ops import ...`, which needs the repo root on `sys.path`.
|
||||
|
||||
`git config core.hooksPath .githooks` (once per clone) enables the pre-push hook that runs
|
||||
`scripts/check.sh`. CI runs on Python 3.10 and 3.12; `requires-python = ">=3.10"`.
|
||||
|
||||
Other entry points:
|
||||
- `python3 build_archive.py` — builds and self-verifies `llamacppctl-installable.tar.gz`
|
||||
(has its own required-file manifest; adding a top-level file that must ship means updating it).
|
||||
- `SMOKE_GPU=1 scripts/smoke.sh` — opt-in end-to-end test against a real Docker + GPU + GGUF model.
|
||||
Not part of the pytest suite.
|
||||
|
||||
## Architecture
|
||||
|
||||
A single CLI (`llamacppctl`) that starts/checks/stops/switches llama.cpp servers running as Docker
|
||||
containers, plus a security-hardened input layer for system/user prompts from text, file, or URL.
|
||||
|
||||
`main.py` is a thin pipeline: parse (`cli.build_parser`) → validate (`cli.validate_args`) →
|
||||
`docker_available()` → resolve prompt sources (`prompt_io`) → resolve config
|
||||
(`config.resolve_effective_config`) → dispatch to `actions.do_*`.
|
||||
|
||||
Layering is deliberate and must be preserved — it keeps the security-critical input validation
|
||||
independently testable from orchestration:
|
||||
- `prompt_io.py` knows nothing about argparse; it works only with `PromptSource` + `InputPolicy`.
|
||||
- `config.py` knows nothing about Docker or HTTP.
|
||||
- `docker_ops.py` / `http_ops.py` know nothing about CLI semantics.
|
||||
- `actions.py` orchestrates; `schema.py` holds the shared dataclasses (`ServerConfig`,
|
||||
`PromptConfig`, `ChatReply`, `CheckResult`).
|
||||
|
||||
Two cross-module couplings to know about:
|
||||
- `main._resolve_prompts()` stashes loaded prompts on the argparse namespace as
|
||||
`args._resolved_system_prompt` / `args._resolved_user_prompt`; `config.py` picks them up there.
|
||||
- `main.run()` calls `docker_available()` *before* config resolution, so `--dry-run` still
|
||||
needs a reachable Docker daemon. Only `--print-effective-config` skips that check — it is a
|
||||
standalone action in the mutually-exclusive group, never a modifier, so it cannot start
|
||||
anything.
|
||||
|
||||
### Config resolution
|
||||
|
||||
INI file (`llama.cpp.config`, template in `llama.cpp.config.example`), lowest to highest priority:
|
||||
builtin defaults (`config.builtin_defaults()`) → `[default]` → `[model.<profile>]` (via `--profile`)
|
||||
→ CLI overrides. `[prompt.<name>]` sections resolve separately and only supply a *fallback* system
|
||||
prompt — an explicit `-s`/`--system-file`/`--system-url` always wins.
|
||||
|
||||
`container_name` is mandatory (empty → `ConfigError`). It is the only identity anchor: Docker
|
||||
`--name`, the `--stop`/`--check` target, and the derived lock path
|
||||
`/tmp/llamacppctl.<container_name>.lock`. Profiles meant to run in parallel need distinct
|
||||
`container_name` *and* `host_port`, otherwise a second `--start` silently replaces the first
|
||||
container.
|
||||
|
||||
`max_tokens` / `chat_temperature` / `connect_timeout` / `read_timeout` affect only the chat request
|
||||
(`--chat` and the reply printed after `--start`), never the container.
|
||||
|
||||
### Exit codes (`actions.py`)
|
||||
|
||||
`0` ok · `1` general/config/prompt error · `2` argparse validation · `3` model path missing ·
|
||||
`4` docker error · `5` HTTP not ready (also `--check` failing) · `6` lock busy.
|
||||
`--check` is meant to be scriptable; keep those codes stable.
|
||||
|
||||
## Invariants worth not breaking
|
||||
|
||||
- All Docker calls go through `subprocess.run()` with **argument lists** — never `shell=True`,
|
||||
never composed shell strings.
|
||||
- The port publishes to `127.0.0.1` by default (`docker_ops._port_publish()`); the llama.cpp
|
||||
OpenAI endpoint is unauthenticated unless `api_key` is set. `--expose` binds all interfaces.
|
||||
- Every HTTP request in `prompt_io` — including each redirect hop — passes `validate_url_target()`:
|
||||
HTTPS-only, no IP literals, no embedded credentials, no `localhost`, all resolved addresses
|
||||
classified (private/loopback/link-local/multicast/reserved/unspecified → reject), metadata IP
|
||||
`169.254.169.254` blocked. `_pin_dns()` then restricts `getaddrinfo` to the already-validated IPs
|
||||
for the duration of the request, closing the TOCTOU/DNS-rebinding window. Size limits are enforced
|
||||
both from `Content-Length` and while streaming.
|
||||
- Escape hatches (`--allow-private-url`, `--allow-insecure-http`, `--allow-ip-host`,
|
||||
`--allow-symlinks`, `--follow-redirects`, `--allow-html-input`) are opt-in and off by default.
|
||||
`--allow-private-url` and `--url-allow-host` are mutually exclusive.
|
||||
- `--start` and `--change` hold an exclusive non-blocking `fcntl.flock`; `--change` validates the
|
||||
model path *before* removing the running container. `--dry-run` takes no lock and touches nothing.
|
||||
|
||||
Tests mock `subprocess` and `requests` (and DNS resolution for the SSRF cases); nothing in the
|
||||
pytest suite requires Docker or the network.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Docs, comments in `docs/`, and the example prompts are in German; source code, docstrings, and
|
||||
commit subjects are in English. Commits follow Conventional Commits (`feat(chat):`, `docs(prompts):`).
|
||||
- Line length 100, `from __future__ import annotations` in every module, `py.typed` is shipped —
|
||||
keep annotations complete enough for `mypy` to pass.
|
||||
- Behavior changes should be reflected in `man/llamacppctl.1`, `CHANGELOG.md`, and — for security-
|
||||
relevant ones — `docs/SECURITY_AND_OPERATIONS.md`.
|
||||
11
README.md
11
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
108
docs/EVAL_RUBRIC.md
Normal file
108
docs/EVAL_RUBRIC.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Bewertungsrubrik für Modell-Prompt-Tests
|
||||
|
||||
Zweck: Modellwechsel (anderes Modell, andere Quantisierung, `Balanced` statt
|
||||
`Aggressive`, MTP, KV-Cache-Einstellungen) sollen an **Zahlen** entschieden
|
||||
werden, nicht an Bauchgefühl. Ohne Vorher-Nachher-Messung ist nicht feststellbar,
|
||||
ob eine Änderung verbessert oder verschlechtert hat.
|
||||
|
||||
Grundlage sind die Läufe unter `~/llamacppctl_prompt_tests/` gegen die Prompts in
|
||||
`example_user_prompts/`. Modelleignung siehe [`KI_TOOLS_PROFILES.md`](KI_TOOLS_PROFILES.md).
|
||||
|
||||
## Zwei Ebenen
|
||||
|
||||
**Objektiv** — von `scripts/eval_prompt_tests.py` automatisch gemessen, keine
|
||||
Meinung nötig:
|
||||
|
||||
| Metrik | Domäne | Bedeutung |
|
||||
|---|---|---|
|
||||
| Wortzahl, Abweichung vom Ziel | prosa, reden | Der Prompt nennt ein Ziel („etwa 1200 Wörtern"). |
|
||||
| Trunkierungsverdacht | alle | Text endet ohne Satzzeichen → Budget zu klein. |
|
||||
| Tests laufen durch | coding | Der generierte Code wird **wirklich ausgeführt**. |
|
||||
| Anteil bestandener Tests | coding | `passed / (passed + failed)`. |
|
||||
|
||||
**Subjektiv** — von Hand oder per LLM-Judge, Skala 1–5. Die Anker unten sind
|
||||
bewusst so formuliert, dass 3 „brauchbar, aber mit Mängeln" bedeutet; ein
|
||||
durchschnittlich guter Text landet nicht automatisch bei 4.
|
||||
|
||||
## Skala
|
||||
|
||||
| Punkte | Bedeutung |
|
||||
|---|---|
|
||||
| 5 | Kriterium durchgängig erfüllt; kein Eingriff nötig. |
|
||||
| 4 | Erfüllt, mit einzelnen Schwächen, die den Gesamteindruck nicht tragen. |
|
||||
| 3 | Brauchbar, aber erkennbare Mängel; Überarbeitung nötig. |
|
||||
| 2 | Kriterium überwiegend verfehlt; Grundgerüst vorhanden. |
|
||||
| 1 | Verfehlt. |
|
||||
|
||||
## Prosa
|
||||
|
||||
1. **Show, don't tell** — Innenleben ausschließlich über Handlung, Gegenstand,
|
||||
Wahrnehmung. *1 = benannte Gefühle („sie war traurig"); 5 = kein einziges
|
||||
benanntes Gefühl, Zustand trotzdem eindeutig.*
|
||||
2. **Schlussbild** — konkretes, bedeutungstragendes Bild statt Moral oder
|
||||
Zusammenfassung. *1 = explizite Lehre; 5 = Bild, das die Geschichte trägt.*
|
||||
3. **Ton- und Registertreue** — hält die geforderte Tonlage (z. B. „nüchtern,
|
||||
ohne Pathos") über den gesamten Text.
|
||||
4. **Sprachliche Präzision** — konkrete Substantive, keine Füllattribute, keine
|
||||
Klischees.
|
||||
5. **Komposition** — Aufbau trägt; kein Leerlauf, kein abrupter Abbruch.
|
||||
6. **Sprachrichtigkeit (Deutsch)** — Grammatik, Kasus, Idiomatik. *Eigener
|
||||
Punkt, weil abliterierte, primär englisch trainierte Modelle hier auffällig
|
||||
sind.*
|
||||
|
||||
## Reden
|
||||
|
||||
1. **Argumentative Substanz** — trägt der Gedankengang, oder reiht er Behauptungen?
|
||||
2. **Vorweggenommene Einwände** — wird der stärkste Gegeneinwand benannt und
|
||||
beantwortet, nicht der schwächste?
|
||||
3. **Benannte Zielkonflikte** — werden echte Interessengegensätze offen
|
||||
ausgesprochen statt harmonisiert?
|
||||
4. **Rhetorische Mittel** — bewusst und sparsam eingesetzt (Trikolon, Anapher,
|
||||
Antithese), nicht dekorativ.
|
||||
5. **Adressatenbezug** — Sprache, Beispiele und Anrede passen zum Publikum.
|
||||
6. **Sprachrichtigkeit (Deutsch)** — siehe oben.
|
||||
|
||||
## Coding
|
||||
|
||||
1. **Korrektheit** — *primär aus dem automatischen Testlauf.* Ein Modul, dessen
|
||||
eigene Tests durchfallen, kann in diesem Kriterium nicht über 2 kommen.
|
||||
2. **Anforderungsabdeckung** — sind alle Punkte des Prompts umgesetzt?
|
||||
3. **Fehlerbehandlung** — die im Prompt geforderten Fehlerfälle, sauber getrennt.
|
||||
4. **Testqualität** — decken die Tests die genannten Fälle ab, und sind sie
|
||||
konsistent zum eigenen Code (richtiger Exception-Typ, Fixtures erfüllen das
|
||||
Schema)?
|
||||
5. **Keine Halluzinationen** — keine erfundenen APIs, Signaturen,
|
||||
Framework-Aussagen oder Benchmark-Messwerte. *Automatisch nicht prüfbar;
|
||||
erfundene Messwerte sind in den bisherigen Läufen wiederholt aufgetreten.*
|
||||
6. **Lesbarkeit** — Benennung, Struktur, Kommentardichte.
|
||||
|
||||
## Durchführung
|
||||
|
||||
- **Blind bewerten.** Die Modellzuordnung steckt im Dateinamen; beim Scoren
|
||||
ausblenden (`scripts/eval_prompt_tests.py --anonymize` schreibt anonymisierte
|
||||
Kopien mit Zufalls-IDs und eine Auflösungstabelle).
|
||||
- **Ein Kriterium über alle Texte**, nicht ein Text über alle Kriterien. Das
|
||||
hält den Maßstab konstant.
|
||||
- **LLM-Judge:** möglich gegen den lokalen Server, aber der Judge darf **nicht**
|
||||
das bewertete Modell sein. Ein Modell bewertet seine eigenen Texte zu gut.
|
||||
Der Judge ersetzt die menschliche Stichprobe nicht — mindestens 20 % der Texte
|
||||
gegenlesen und die Übereinstimmung prüfen.
|
||||
- **Mindestens zwei Läufe pro Zelle**, weil bei `temp > 0` ein Einzellauf wenig
|
||||
aussagt. Für Coding-Vergleiche `--seed` fixieren.
|
||||
|
||||
## Was die Zahlen nicht sagen
|
||||
|
||||
Ein bestandener Testlauf beweist, dass der Code *seine eigenen* Tests besteht —
|
||||
nicht, dass er korrekt ist. Wenn Modell und Test aus derselben Halluzination
|
||||
stammen, sind beide konsistent falsch. Kriterium 4 (Testqualität) ist deshalb
|
||||
nicht redundant zu Kriterium 1, sondern dessen Korrektiv.
|
||||
|
||||
## Sicherheitshinweis
|
||||
|
||||
`scripts/eval_prompt_tests.py` führt **modellgenerierten Code aus**. Das ist der
|
||||
Sinn der Übung, aber es ist Codeausführung aus einer nicht vertrauenswürdigen
|
||||
Quelle. Der Runner arbeitet in einem temporären Verzeichnis, erzwingt ein
|
||||
Zeitlimit und verweigert den Start als `root`. Er bietet **keine** Netz- oder
|
||||
Dateisystem-Isolation. Wer die Läufe auf einem Rechner mit Produktionsdaten
|
||||
auswertet, sollte `--no-exec` verwenden oder das Skript in einem Container
|
||||
starten.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
169
docs/KI_TOOLS_PROFILES.md
Normal file
169
docs/KI_TOOLS_PROFILES.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Modellprofile — Eignung der lokalen GGUF-Modelle
|
||||
|
||||
Dieses Dokument beschreibt, **wofür** sich die Modelle unter
|
||||
`$HF_HOME/models/qwen3/` jeweils eignen, unabhängig von den Beispiel-Prompts in
|
||||
`example_system_prompts/`. Grundlage sind die Modellkarten auf Hugging Face
|
||||
(Stand Juli 2026) sowie die eigenen Testläufe unter `~/llamacppctl_prompt_tests/`.
|
||||
|
||||
> Die Angaben zu Fähigkeiten und Benchmarks stammen, wo nicht anders vermerkt,
|
||||
> **von den Modellautoren selbst** und sind nicht unabhängig verifiziert.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Datei | Profil | Basis | Primäre Eignung |
|
||||
|---|---|---|---|
|
||||
| `ornith-1.0-35b-Q4_K_M.gguf` | `ornith35b` | Qwen3.5-MoE | Agentisches Coding |
|
||||
| `Carnice-Qwen3.6-MoE-35B-A3B-Q4_K_M.gguf` | `carnice` | Qwen3.6-35B-A3B | Agenten-/Tool-Calling-Workflows |
|
||||
| `Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf` | `qwopus` | Qwen3.6-35B-A3B | Reasoning, Vision, Tool-Calling |
|
||||
| `Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf` | `[default]` | Qwen3.6-35B-A3B | Kreatives Schreiben ohne Verweigerungen |
|
||||
| `Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-IQ4_XS.gguf` | `qwen27b` | Qwen3.6-27B (dense) | Wie oben, kleinerer VRAM-Bedarf |
|
||||
| `mmproj.gguf` | — | Qwen3.6-35B-A3B | Vision-Projektor (kein eigenständiges Modell) |
|
||||
|
||||
Alle 35B-A3B-Modelle sind MoE mit **3 B aktiven Parametern** pro Token: hoher
|
||||
Durchsatz bei 21 GB VRAM-Bedarf. Das 27B ist dense — langsamer pro Token, aber
|
||||
nur ~15 GB.
|
||||
|
||||
## Ornith-1.0-35B — agentisches Coding
|
||||
|
||||
Quelle: [`deepreinforce-ai/Ornith-1.0-35B-GGUF`](https://huggingface.co/deepreinforce-ai/Ornith-1.0-35B-GGUF) (MIT)
|
||||
|
||||
Ein dediziertes Coding-Modell aus einer selbstverbessernden Modellfamilie. Das
|
||||
Trainings-Framework erzeugt per RL nicht nur Lösungs-Rollouts, sondern optimiert
|
||||
auch das treibende Scaffold gemeinsam mit ihnen. Beworben mit SOTA unter
|
||||
vergleichbar großen Open-Source-Modellen auf Terminal-Bench 2.1, SWE-Bench,
|
||||
NL2Repo und OpenClaw. Reasoning-Modell: die Assistant-Antwort beginnt mit einem
|
||||
`<think>`-Block.
|
||||
|
||||
**Einsetzen für:** Code-Generierung, Refactoring, Repo-weite Aufgaben,
|
||||
Agenten-Loops mit Tool-Calls.
|
||||
**Nicht einsetzen für:** Prosa und Reden — dafür ist das Sampling im Profil
|
||||
(`temp = 0.20`) bewusst deterministisch gewählt.
|
||||
|
||||
Beachte: Basis ist **Qwen3.5**-MoE (`qwen3_5_moe`), nicht 3.6. Die lokale
|
||||
`mmproj.gguf` passt deshalb *nicht* zu diesem Modell.
|
||||
|
||||
## Carnice-Qwen3.6-MoE-35B-A3B — Agenten-Workflows
|
||||
|
||||
Quelle: [`samuelcardillo/Carnice-Qwen3.6-MoE-35B-A3B`](https://huggingface.co/samuelcardillo/Carnice-Qwen3.6-MoE-35B-A3B)
|
||||
|
||||
QLoRA-Finetune von Qwen3.6-35B-A3B, trainiert auf echten Execution-Traces der
|
||||
Hermes-Agent-Runtime. Das erklärte Ziel ist, dem Modell die konkreten
|
||||
Konversationsmuster beizubringen, die ein Agenten-Runtime erwartet — nicht
|
||||
generisches Reasoning.
|
||||
|
||||
**Einsetzen für:** Tool-Calling-Pipelines, Agenten-Orchestrierung (z. B. n8n),
|
||||
strukturierte Mehrschritt-Abläufe.
|
||||
|
||||
## Qwopus3.6-35B-A3B-v1 — Reasoning + Vision
|
||||
|
||||
Quelle: [`Jackrong/Qwopus3.6-35B-A3B-v1`](https://huggingface.co/Jackrong/Qwopus3.6-35B-A3B-v1)
|
||||
|
||||
Reasoning-verstärkter Finetune, dreistufiges SFT mit progressiv steigender
|
||||
Reasoning-Komplexität. Unterstützt Vision und Tool-Calling.
|
||||
|
||||
**Einsetzen für:** Aufgaben mit langer Gedankenkette, multimodale Eingaben.
|
||||
**Vorbehalt:** Der Autor kennzeichnet das Modell ausdrücklich als experimentell,
|
||||
ohne vollständige Performance- oder Sicherheitsevaluation.
|
||||
|
||||
Für reines Coding gibt es die separate Variante
|
||||
[`Qwopus3.6-35B-A3B-Coder-MTP`](https://huggingface.co/Jackrong/Qwopus3.6-35B-A3B-Coder-MTP-GGUF)
|
||||
(thinking-off, 62,4 % auf einem 300-Fall-SWE-Bench-Lauf) — lokal nicht vorhanden.
|
||||
|
||||
## HauhauCS Uncensored (35B-A3B und 27B) — kreatives Schreiben
|
||||
|
||||
Quellen: [35B-A3B](https://huggingface.co/HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive),
|
||||
[27B](https://huggingface.co/HauhauCS/Qwen3.6-27B-Uncensored-HauhauCS-Aggressive)
|
||||
|
||||
**Abliterierte** Modelle: keine neuen Fähigkeiten, entfernte Verweigerungen
|
||||
(Modellkarte: „0/465 Refusals"). Alle Quants mit imatrix erzeugt. Beide sind
|
||||
multimodal.
|
||||
|
||||
**Einsetzen für:** Prosa, Reden, Dialog, Rollenspiel — Themen, bei denen ein
|
||||
aligntes Modell abbricht.
|
||||
**Nicht einsetzen für:** Coding. Eigene Testläufe zeigen wiederkehrend Tests, die
|
||||
inkonsistent zum selbst erzeugten Schema sind, erfundene Framework-Aussagen und
|
||||
Benchmarks mit erfundenen Messwerten.
|
||||
|
||||
Zwei Punkte, die in der aktuellen Konfiguration relevant sind:
|
||||
|
||||
1. Die Karte des 27B empfiehlt selbst, dass **99,9 % der Nutzer die
|
||||
`Balanced`-Variante** nehmen sollten statt `Aggressive`. Beide erreichen
|
||||
dieselbe Refusal-Rate; `Balanced` bietet zusätzlich stabileres Sampling für
|
||||
agentic coding, Tool-Use und Reasoning. `Aggressive` spart lediglich
|
||||
Vorreden. Aktuell sind beide lokalen HauhauCS-Modelle `Aggressive`.
|
||||
2. Die Behauptungen „lossless" und „zero capability loss" stammen vom Autor der
|
||||
Modellkarte. Abliteration kostet in aller Regel *etwas* Fähigkeit.
|
||||
|
||||
## Empirische Befunde (eigene Läufe)
|
||||
|
||||
Getestet gegen das Default-Modell (HauhauCS 35B-A3B), Reasoning aktiv, ctx 262144:
|
||||
|
||||
- **Längenvorgaben werden nicht präzise getroffen** (−27 % bis +26 % über sechs
|
||||
Läufe). Länge als Spanne vorgeben oder im zweiten Turn nachjustieren.
|
||||
- **Reasoning frisst das Antwortbudget.** Bei `--max-tokens 10000` endeten
|
||||
schwere Aufgaben in `finish_reason=length`. Für Prosa/Reden/Dialog
|
||||
`--max-tokens` ≥ 16000 wählen.
|
||||
- **`--chat` ohne `--stream`** läuft bei langem Output in den Read-Timeout.
|
||||
Immer `--stream` verwenden (jeder Token setzt den Timeout zurück).
|
||||
- **Coding: Struktur gut, Korrektheit unzuverlässig.** Generierten Code und
|
||||
generierte Tests immer wirklich ausführen.
|
||||
|
||||
Rohdaten: `~/llamacppctl_prompt_tests/`.
|
||||
|
||||
## Vision (mmproj)
|
||||
|
||||
Die lokale `mmproj.gguf` (902 MB) ist der Vision-Encoder des **Basismodells
|
||||
Qwen3.6-35B-A3B** (aus dem unsloth-Repo). GGUF-Metadaten:
|
||||
|
||||
```
|
||||
general.architecture = clip
|
||||
general.name = Qwen3.6 35B A3B
|
||||
clip.projector_type = qwen3vl_merger
|
||||
clip.has_vision_encoder
|
||||
```
|
||||
|
||||
Ein mmproj ist kein eigenständiges Modell, sondern projiziert Bild-Embeddings in
|
||||
den Textmodell-Raum. Er muss zum Vision-Tower der jeweiligen Basis passen.
|
||||
|
||||
| Modell | mmproj.gguf passt? |
|
||||
|---|---|
|
||||
| HauhauCS 35B-A3B | ja (Basis Qwen3.6-35B-A3B) |
|
||||
| Qwopus3.6-35B-A3B | ja (dieselbe Basis) |
|
||||
| Carnice-Qwen3.6-MoE-35B-A3B | ja (dieselbe Basis) |
|
||||
| `ornith-1.0-35b` | **nein** — Basis Qwen3.5-MoE, eigener Projektor nötig |
|
||||
| HauhauCS 27B (dense) | **nein** — anderer Vision-Encoder |
|
||||
|
||||
Verwendung über `llamacppctl` (siehe `[model.vision]` in `llama.cpp.config`):
|
||||
|
||||
```bash
|
||||
llamacppctl --start --config llama.cpp.config --profile vision
|
||||
```
|
||||
|
||||
Das Profil setzt `mmproj = models/qwen3/mmproj.gguf`; der Pfad wird — wie
|
||||
`model_path` — relativ zu `hf_home` aufgelöst und als `--mmproj` an den
|
||||
llama.cpp-Server durchgereicht. Der Projektor wird standardmäßig auf die GPU
|
||||
ausgelagert; `mmproj_offload = false` bzw. `--no-mmproj-offload` verhindert das,
|
||||
wenn der VRAM knapp ist.
|
||||
|
||||
Bilder gehen anschließend über den OpenAI-kompatiblen Endpunkt
|
||||
`/v1/chat/completions` als `image_url`-Content-Part. `llamacppctl --chat` sendet
|
||||
derzeit nur Text; für Bild-Requests den Endpunkt direkt ansprechen.
|
||||
|
||||
## MTP / Speculative Decoding (nicht lokal vorhanden)
|
||||
|
||||
Für Ornith, Carnice und Qwopus existieren **MTP-Quants** mit eingebettetem
|
||||
Multi-Token-Prediction-Head. Mit hinreichend aktuellem llama.cpp erlauben sie
|
||||
self-speculative Decoding ohne separates Draft-Modell (`--draft-mtp`);
|
||||
Community-Berichte nennen etwa doppelten Decode-Durchsatz bei ~2,5 % größerer
|
||||
Datei. Die im pyproject gepinnte Image-Version muss das unterstützen.
|
||||
|
||||
## Quellen
|
||||
|
||||
- <https://huggingface.co/deepreinforce-ai/Ornith-1.0-35B-GGUF>
|
||||
- <https://huggingface.co/SC117/Ornith-1.0-35B-MTP-APEX-GGUF>
|
||||
- <https://huggingface.co/samuelcardillo/Carnice-Qwen3.6-MoE-35B-A3B>
|
||||
- <https://huggingface.co/Jackrong/Qwopus3.6-35B-A3B-v1>
|
||||
- <https://huggingface.co/Jackrong/Qwopus3.6-35B-A3B-Coder-MTP-GGUF>
|
||||
- <https://huggingface.co/HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive>
|
||||
- <https://huggingface.co/HauhauCS/Qwen3.6-27B-Uncensored-HauhauCS-Aggressive>
|
||||
- <https://github.com/ggml-org/llama.cpp/blob/master/docs/multimodal.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ...`.
|
||||
|
|
|
|||
457
scripts/eval_prompt_tests.py
Executable file
457
scripts/eval_prompt_tests.py
Executable file
|
|
@ -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-<grund>
|
||||
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 ``[<modell>_]<domaene>_<nr>[_<variante>].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())
|
||||
97
scripts/run_prompt_suite.sh
Executable file
97
scripts/run_prompt_suite.sh
Executable file
|
|
@ -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:
|
||||
#
|
||||
# <label>_<domaene>_<nr>.out.txt
|
||||
#
|
||||
# Der Server fuer <profil> muss bereits laufen (llamacppctl --start --profile ...).
|
||||
# Ausgewertet wird anschliessend mit scripts/eval_prompt_tests.py.
|
||||
#
|
||||
# Verwendung:
|
||||
# scripts/run_prompt_suite.sh <profil> <label> <domaene> [<domaene>...]
|
||||
#
|
||||
# Beispiel (Kontrolllauf des nicht-abliterierten Basismodells auf Prosa):
|
||||
# scripts/run_prompt_suite.sh qwen35base qwen35base prosa
|
||||
#
|
||||
# Environment:
|
||||
# OUT_DIR Zielverzeichnis (Default: ~/llamacppctl_prompt_tests)
|
||||
# CONFIG Config-Datei (Default: llama.cpp.config)
|
||||
# MAX_TOKENS Antwortbudget (Default: 20000 -- Reasoning frisst viel; unter
|
||||
# 16000 brechen Prosa/Reden mitten im Denken ab)
|
||||
# CLI Pfad zum CLI (Default: ./.venv/bin/llamacppctl)
|
||||
# OVERWRITE 1 = vorhandene Ausgaben ueberschreiben (Default: 0 = ueberspringen)
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||
|
||||
if [ "$#" -lt 3 ]; then
|
||||
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PROFILE=$1; LABEL=$2; shift 2
|
||||
OUT_DIR=${OUT_DIR:-"$HOME/llamacppctl_prompt_tests"}
|
||||
CONFIG=${CONFIG:-llama.cpp.config}
|
||||
MAX_TOKENS=${MAX_TOKENS:-20000}
|
||||
CLI=${CLI:-./.venv/bin/llamacppctl}
|
||||
OVERWRITE=${OVERWRITE:-0}
|
||||
|
||||
if [ ! -x "$CLI" ]; then
|
||||
echo "CLI nicht ausfuehrbar: $CLI" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ohne laufenden Server produziert jeder Prompt nur eine Fehlermeldung als
|
||||
# "Ergebnis" -- lieber sofort abbrechen als 4 Muelldateien schreiben.
|
||||
if ! "$CLI" --check --config "$CONFIG" --profile "$PROFILE" >/dev/null 2>&1; then
|
||||
echo "Server fuer Profil '$PROFILE' ist nicht erreichbar (--check schlug fehl)." >&2
|
||||
echo "Zuerst starten: $CLI --start --config $CONFIG --profile $PROFILE" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
fail=0
|
||||
|
||||
for domain in "$@"; do
|
||||
system_prompt="example_system_prompts/system_prompt_${domain}.md"
|
||||
if [ ! -f "$system_prompt" ]; then
|
||||
echo "System-Prompt fehlt: $system_prompt" >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
for prompt in example_user_prompts/"${domain}"_[0-9][0-9]_*.md; do
|
||||
[ -e "$prompt" ] || continue
|
||||
base=$(basename "$prompt" .md) # z. B. prosa_01_werkstatt
|
||||
number=$(echo "$base" | cut -d_ -f2) # z. B. 01
|
||||
out="$OUT_DIR/${LABEL}_${domain}_${number}.out.txt"
|
||||
|
||||
if [ -e "$out" ] && [ "$OVERWRITE" != "1" ]; then
|
||||
echo "== $base -> vorhanden, uebersprungen (OVERWRITE=1 erzwingt)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "== $base -> $out"
|
||||
if "$CLI" --chat --config "$CONFIG" --profile "$PROFILE" \
|
||||
--system-file "$system_prompt" \
|
||||
--prompt-file "$prompt" \
|
||||
--stream --max-tokens "$MAX_TOKENS" > "$out.part" 2>"$out.err"; then
|
||||
mv "$out.part" "$out"
|
||||
rm -f "$out.err"
|
||||
printf ' %s Woerter\n' "$(wc -w < "$out")"
|
||||
else
|
||||
echo " FEHLGESCHLAGEN -- siehe $out.err" >&2
|
||||
rm -f "$out.part"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "Mindestens ein Lauf schlug fehl."
|
||||
exit 1
|
||||
fi
|
||||
echo "Fertig. Auswerten mit: scripts/eval_prompt_tests.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 = {
|
||||
|
|
|
|||
|
|
@ -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.<profile>] in config")
|
||||
|
|
@ -46,6 +54,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")
|
||||
|
|
@ -134,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
|
||||
|
||||
|
|
@ -185,3 +200,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")
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,9 +76,6 @@ 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
|
||||
|
||||
if args.start:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
57
tests/test_main.py
Normal file
57
tests/test_main.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue