99 lines
3.7 KiB
Python
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 ""
|