"""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"", # 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"", prompt) return m.group(1) if m else ""