Zum Inhalt

touchstone/gui/packs.py

"""Pure helpers for the in-browser pack editor: validate pack YAML through the *one*
pydantic contract (``touchstone.pack``) and confine a save filename. No file I/O, never
raises — the routes stay thin over these. Mirrors the shape of ``configs.py``."""

from __future__ import annotations

import re
from typing import Any

import yaml
from pydantic import ValidationError

from touchstone.pack import Pack

# A minimal-but-valid pack: the starting point for "+ Neues Pack". Editing then leans on
# the live validator + preview, so this only has to pass Pack.model_validate.
NEW_PACK_TEMPLATE = """\
id: neues_pack
title: Neues Pack
version: 1
description: ""

# Die 1..5-Skala (Pflicht: genau die Schlüssel 1 bis 5).
scale:
  1: "untauglich"
  2: "schwach"
  3: "brauchbar"
  4: "gut"
  5: "exzellent"

# Gewichtete Querschnitts-Dimensionen (Q1..Qn). weight muss positiv sein.
dimensions:
  - {id: Q1, name: "Fachliche Qualität", weight: 2}
  - {id: Q2, name: "Sicherheit", weight: 1}

# Sicherheits-K.-o.: eine Dimension-Untergrenze + (optional) fatale red-flag-Prompts.
ko_rule:
  dimension: Q2
  threshold: 2
  red_flag_prompts: []

# Die System-Prompt-Achse (Prompt-Benchmarking). Mindestens eine Variante.
prompt_variants:
  - {id: none, system_prompt: null}

# Deterministisch per Default (reproduzierbar/vergleichbar).
sampling:
  temperature: 0.0
  seed: 42

# Die Test-Prompts, gruppiert in Kategorien.
categories:
  - id: A
    name: Allgemein
    prompts:
      - id: A1
        title: Beispiel-Prompt
        prompt: "Stelle hier deine Test-Frage."
        tests: "Worauf der Judge achten soll."
        green_flags: []
        red_flags: []
"""


def _format_errors(exc: ValidationError) -> list[dict[str, str]]:
    """pydantic ValidationError → [{loc: dotted-path, msg}]. The dotted loc lets the editor
    point at the offending field (e.g. ``dimensions.0.weight``)."""
    out: list[dict[str, str]] = []
    for e in exc.errors():
        # model-level validators (e.g. duplicate prompt ids) carry loc=() → label them "(pack)"
        # so the editor never shows a bare " — message" with an empty field prefix.
        loc = ".".join(str(p) for p in e.get("loc", ())) or "(pack)"
        out.append({"loc": loc, "msg": str(e.get("msg", ""))})
    return out


def validate_pack_yaml(text: str) -> dict[str, Any]:
    """Parse + validate pack YAML against the pydantic contract. Pure, never raises.

    Returns ``{ok, errors, summary, pack}``: on success ``pack`` is the validated ``Pack``
    (so the route renders the preview without re-validating) and ``summary`` carries the
    counts; on any failure ``pack``/``summary`` are ``None`` and ``errors`` is non-empty.
    """
    try:
        raw = yaml.safe_load(text)
    except yaml.YAMLError as e:
        return {
            "ok": False,
            "errors": [{"loc": "(yaml)", "msg": str(e)}],
            "summary": None,
            "pack": None,
        }
    if not isinstance(raw, dict):
        return {
            "ok": False,
            "errors": [{"loc": "(root)", "msg": "Pack-YAML muss ein Mapping (Objekt) sein."}],
            "summary": None,
            "pack": None,
        }
    try:
        pack = Pack.model_validate(raw)
    except ValidationError as e:
        return {"ok": False, "errors": _format_errors(e), "summary": None, "pack": None}
    summary = {
        "prompts": len(pack.all_prompts()),
        "dimensions": len(pack.dimensions),
        "variants": len(pack.prompt_variants),
        "categories": len(pack.categories),
        "max_weighted": pack.max_weighted,
    }
    return {"ok": True, "errors": [], "summary": summary, "pack": pack}


def safe_pack_filename(name: str) -> str | None:
    """Confine a save target to ``packs/<name>.yaml``. Returns the bare ``name.yaml`` or None.

    Accepts a stem of ``[A-Za-z0-9_-]+`` (with an optional ``.yaml`` suffix); rejects path
    separators, traversal, other extensions and empty stems — so it can never escape ``packs/``.
    """
    name = name.strip()
    if name.endswith(".yaml"):
        stem = name[:-5]
    elif "." in name:
        return None  # some other extension (.yml/.json/…) — only .yaml is written
    else:
        stem = name
    if not re.fullmatch(r"[A-Za-z0-9_-]+", stem):
        return None
    return stem + ".yaml"