Files
creator3/backend/skills.py
2026-07-23 16:07:36 +02:00

99 lines
3.7 KiB
Python

"""Skill-Loader + Komposition (R5): genau 1 Rolle + 1 Aufgabe + 0..n Stil.
Nur die Aufgabe definiert das Ausgabeformat (Konfliktfreiheit per Konstruktion).
{{platzhalter}} nur im Aufgabe-Body, str.replace statt format_map.
Hashing zweistufig: je Datei sha256[:12], skill_hash über die geordnete Liste."""
import hashlib
import re
from pathlib import Path
from . import config
_FRONT_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)
_PLATZHALTER_RE = re.compile(r"\{\{([a-z_]+)\}\}")
_cache: dict[str, dict] = {}
class SkillFehler(Exception):
pass
def _parse(pfad: Path) -> dict:
text = pfad.read_text(encoding="utf-8")
m = _FRONT_RE.match(text)
if not m:
raise SkillFehler(f"{pfad.name}: kein Frontmatter")
meta: dict = {"platzhalter": []}
for zeile in m.group(1).splitlines():
if ":" not in zeile:
continue
k, v = zeile.split(":", 1)
k, v = k.strip(), v.strip()
if k == "platzhalter":
meta[k] = [p.strip() for p in v.strip("[]").split(",") if p.strip()]
else:
meta[k] = v
for pflicht in ("name", "art", "beschreibung"):
if not meta.get(pflicht):
raise SkillFehler(f"{pfad.name}: Frontmatter-Feld '{pflicht}' fehlt")
if meta["art"] not in ("rolle", "aufgabe", "stil"):
raise SkillFehler(f"{pfad.name}: unbekannte art '{meta['art']}'")
body = m.group(2).strip()
return {**meta, "body": body,
"hash": hashlib.sha256(text.encode()).hexdigest()[:12]}
def lade(name: str) -> dict:
"""Skill per Name laden ('rolle/extraktor' oder nur 'extraktor' mit Suche)."""
if name in _cache and not config.FAKE:
return _cache[name]
if "/" in name:
pfad = config.SKILLS_DIR / f"{name}.md"
else:
treffer = list(config.SKILLS_DIR.glob(f"*/{name}.md"))
if len(treffer) != 1:
raise SkillFehler(f"Skill '{name}': {len(treffer)} Treffer")
pfad = treffer[0]
if not pfad.exists():
raise SkillFehler(f"Skill-Datei fehlt: {pfad}")
_cache[name] = _parse(pfad)
return _cache[name]
def komponieren(skill_namen: list[str], werte: dict) -> tuple[str, str, list]:
"""→ (prompt, skill_hash, [(name, hash)]). Reihenfolge Rolle→Stil→Aufgabe;
Prompt beginnt mit stabilem Präfix (MiniMax-Prompt-Caching ab 512 Tokens)."""
geladen = [lade(n) for n in skill_namen]
rollen = [s for s in geladen if s["art"] == "rolle"]
aufgaben = [s for s in geladen if s["art"] == "aufgabe"]
stile = [s for s in geladen if s["art"] == "stil"]
if len(rollen) != 1 or len(aufgaben) != 1:
raise SkillFehler(f"Komposition braucht genau 1 Rolle + 1 Aufgabe: {skill_namen}")
aufgabe = aufgaben[0]
fehlt = [p for p in aufgabe["platzhalter"] if p not in werte]
if fehlt:
raise SkillFehler(f"Aufgabe '{aufgabe['name']}': Platzhalter fehlen: {fehlt}")
body = aufgabe["body"]
for p in aufgabe["platzhalter"]:
body = body.replace("{{" + p + "}}", str(werte[p]))
rest = _PLATZHALTER_RE.search(body)
if rest:
raise SkillFehler(f"Aufgabe '{aufgabe['name']}': unersetzt: {rest.group(0)}")
teile = [f"<!-- aufgabe:{aufgabe['name']} -->", # Fake-Registry-Schlüssel
"## Rolle", rollen[0]["body"]]
if stile:
teile += ["## Arbeitsweise"] + [s["body"] for s in stile]
teile += ["## Aufgabe", body]
prompt = "\n\n".join(teile)
paare = [(s["name"], s["hash"]) for s in rollen + stile + [aufgabe]]
skill_hash = hashlib.sha256(
"\n".join(f"{n}:{h}" for n, h in paare).encode()).hexdigest()[:12]
return prompt, skill_hash, paare
def aufgaben_name(prompt: str) -> str:
m = re.match(r"<!-- aufgabe:([a-z0-9-]+) -->", prompt)
return m.group(1) if m else ""