Spec: Pack-Editor (Guided YAML)¶
Datum: 2026-06-25 · Branch: feat/gui-pack-editor · Status: approved
Problem / Ziel¶
pack.html ist heute ein reiner Viewer. B3b macht daraus einen Editor: ein Pack
laden/sehen/anpassen/exportieren — in-browser, ohne Terminal-Umweg. Ein Pack ist handgeschriebenes
YAML mit tiefer, verschachtelter Struktur (scale · dimensions+Gewichte · ko_rule · prompt_variants ·
sampling · categories→prompts mit green/red-Flags · Rubrik · safety_critical/format_strict). Statt
eines riesigen, fragilen Formulars: ein YAML-Editor mit starkem Sicherheitsnetz —
Server-Validierung über die eine pydantic-Schicht + Live-Vorschau, die den Viewer recycelt.
Nicht-Ziele¶
- Kein Formular-Editor für die verschachtelte Struktur (YAGNI; Packs sind YAML, der User schreibt sie von Hand).
- Keine Client-seitige Validierung (würde
pack.py-Regeln duplizieren → Drift). Validierung ist immer server-seitig. - Keine Änderung an
pack.py(Datenkontrakt + Validierung bleiben die Quelle der Wahrheit). - Keine Migration/Versionierung bestehender Bundles. Editieren ändert die Quelle für künftige Läufe; bestehende Bundles sind self-contained und bleiben unberührt.
Design¶
Pure Logik — touchstone/gui/packs.py (neues Modul, spiegelt configs.py)¶
def validate_pack_yaml(text: str) -> dict[str, Any]:
"""Parse + validate pack YAML. Pure (text in, no file I/O). NEVER raises.
{ok: bool, errors: [{loc: str, msg: str}], summary: {...}|None, pack: Pack|None}."""
yaml.safe_load → Pack.model_validate. On success pack is the validated Pack (so the
route renders the preview without re-validating); pack=None on any failure. The route returns
{ok, errors, summary, preview_html} — pack stays server-side, never in the JSON.
- YAMLError → ok=False, errors=[{loc:"(yaml)", msg:<parse error>}].
- ValidationError → ok=False, errors aus e.errors() (loc als dotted path, msg).
- Nicht-Mapping/leer → ok=False mit klarer Meldung.
- Erfolg → ok=True, summary={prompts, dimensions, variants, categories, max_weighted} (aus dem
validierten Pack: len(all_prompts()), len(dimensions), len(prompt_variants),
len(categories), max_weighted()).
def safe_pack_filename(name: str) -> str | None:
"""Confine a save target to packs/<name>.yaml. Returns the bare 'name.yaml' or None.
Accept ^[A-Za-z0-9_-]+(.yaml)?$ ; reject traversal, separators, other extensions, empty."""
NEW_PACK_TEMPLATE: str — eine minimal valide Pack-YAML (1 Dimension, ko_rule darauf, variant
none, 1 Kategorie/1 Prompt) als Startpunkt für „+ Neues Pack".
Template-Refactor — Vorschau recycelt den Viewer¶
- Den Pack-Body (Dimensionen-, Varianten-, Prompts-Karten) aus
pack.htmlnachmacros/_pack_body.htmlextrahieren (nimmtpack, nutztg). pack.htmlbindet das Partial ein (behält Breadcrumb/Header/Description/Method-Explainer) und bekommt einen „✎ Bearbeiten"-Link →/pack-editor?path=<datei>.- Die Editor-Vorschau rendert nur
_pack_body.htmlzu einem HTML-String (_templates.get_template(...).render(pack=pk, g=_glossary.describe)— keinrequestnötig).
Routen — touchstone/gui/app.py¶
GET /pack-editor(?path=optional) → rendertpack_editor.html. Mit gültigem?path(confined aufpacks/*.yaml-Glob, wie der Viewer) → rohen YAML-Text der Datei in die Textarea; sonstNEW_PACK_TEMPLATE. Übergibt die editierbaren Pack-Dateien für ein „Laden"-Dropdown.POST /packs/validate(Formyaml_text) →validate_pack_yaml→ JSON{ok, errors, summary, preview_html}. Beiokistpreview_htmldas gerenderte_pack_body.html, sonstnull. Nie 500 (alles gefangen). Größen-Cap aufyaml_text(z. B. 1 MB).POST /packs/save(Formfilename,yaml_text,overwrite) →safe_pack_filename(sonst 400) ·validate_pack_yamlmussoksein (sonst 400, nie ungültig schreiben) · Pfadpacks/<name>.yamlresolved +is_relative_to(packs/)· existiert & nichtoverwrite→ 409 · sonst schreiben →{saved: "<name>.yaml"}.- CSRF/Origin-Guard + TrustedHost greifen automatisch auf die POSTs (bestehende Middleware).
- Download = client-seitig (Blob der Textarea, verbatim — behält Kommentare/Anker/Reihenfolge); kein Server-Round-Trip, keine Validierungs-Hürde für einen Entwurf. Nur Speichern validiert.
Frontend — pack_editor.html + static/pack_editor.js¶
Alpine packEditor: {files, path, yamlText, ok, errors, summary, previewHtml, saving, saveError}.
- Textarea x-model="yamlText"; „Laden"-Dropdown (files) navigiert zu /pack-editor?path=.
- „Validieren" → POST /packs/validate → setzt ok/errors/summary/previewHtml. Außerdem
validate-on-load (init). (Optional später: debounced Auto-Validate.)
- Vorschau-Pane x-html="previewHtml" bei ok; sonst Fehlerliste (loc · msg) + Summary-Zeile.
- „Herunterladen" → Blob-Download von yamlText.
- „In packs/ speichern" → Dateiname-Feld (vorbelegt aus path oder neuer Name) → POST /packs/save;
bei 409 → Overwrite-Bestätigung → erneut mit overwrite=true. Save :disabled solange !ok.
- pack_editor.js non-deferred laden (wie model_picker.js), damit packEditor vor
alpine:init registriert ist.
Tests (TDD)¶
Pure validate_pack_yaml (tests/test_gui_packs.py):
- valide YAML → ok, Summary-Zähler korrekt.
- Parse-Fehler (kaputte YAML) → ok=False, Fehler mit Parse-Meldung.
- Schema-Verstöße je eigener Test: scale ≠ 1..5 · dangling ko_rule.dimension · doppelte prompt-id ·
weight ≤ 0 → ok=False, errors tragen loc+msg.
- Nicht-Mapping/leer → ok=False.
Pure safe_pack_filename: "foo"→"foo.yaml" · "foo.yaml"→"foo.yaml" · "../x"/"a/b"/""/"x.yml"/Sonderzeichen → None.
Routen (tests/test_gui_pack_editor.py, TestClient; Save-Tests monkeypatch.chdir(tmp_path) +
mkdir packs):
- GET /pack-editor rendert; ?path=packs/ndassist.yaml lädt rohen YAML-Text; ohne path → Template.
- POST /packs/validate valid → ok, preview_html enthält Pack-Marker (z. B. „Dimensionen").
- POST /packs/validate invalid → ok=False, errors nicht leer, preview_html=null.
- POST /packs/save valides neues Pack → Datei unter packs/ geschrieben, {saved}.
- POST /packs/save invalide YAML → 400, kein Write.
- POST /packs/save bad filename (../x) → 400/404, kein Write.
- POST /packs/save existierend ohne overwrite → 409; mit overwrite=true → 200, überschrieben.
- Pfad-Guards/Traversal auf /pack-editor?path=.
Bestehende Tests: test_gui_pack_view.py muss grün bleiben (Refactor 1:1 — Viewer rendert
weiterhin Dimensionen/Varianten/Prompts). „Bearbeiten"-Link additiv.
Headless-Smoke (Memory gui-restart-after-changes): frischer Server, /pack-editor lädt 200 mit
Editor; /packs/validate (valid + invalid) liefert erwartete Shape; serviertes pack_editor.js
identisch zur Datei.
Implementierungs-Reihenfolge (Plan)¶
- Pure
packs.py(validate_pack_yaml,safe_pack_filename,NEW_PACK_TEMPLATE) + Unit-Tests (RED→GREEN). - Template-Refactor
_pack_body.html+pack.html-Include + „Bearbeiten"-Link;test_gui_pack_viewgrün halten. - Routen
/pack-editor,/packs/validate,/packs/save+ Route-Tests (RED→GREEN). pack_editor.html+pack_editor.js+ Template-Marker-Tests.- Volle Suite + mypy + ruff (check & format) + Headless-Smoke.
- Adversariale Whole-Branch-Review → bestätigte Funde fixen → Merge nach main + Push.
Berührte Dateien¶
touchstone/gui/packs.py(neu) ·touchstone/gui/app.py(3 Routen)touchstone/gui/templates/macros/_pack_body.html(neu) ·pack.html(Include + Link)touchstone/gui/templates/pack_editor.html(neu) ·touchstone/gui/static/pack_editor.js(neu)tests/test_gui_packs.py(neu) ·tests/test_gui_pack_editor.py(neu)
Risiken / Edge-Cases¶
- Ungültiges Pack nie schreiben — Save validiert hart vor dem Write.
- Pfad-Confinement —
safe_pack_filename+is_relative_to(packs/), wie derimport-bundle-Write. - Großer YAML-Body — Größen-Cap auf
yaml_text. - Editieren eines schon gelaufenen Packs — bestehende Bundles bleiben unberührt (self-contained);
Editor weist darauf hin,
version-Bump liegt beim User. g-Glossar in der Vorschau — Partial via Templates-Env gerendert,gist Env-global → verfügbar.