Eigene Skills schreiben — PDF-Parser-Skill mit pytest TDD
Artikel 3 · Serie: Agentic Coding mit Claude Code
Mit v0.2 liegt die Karte: docs/architecture.md beschreibt die PDF-Struktur der bayerischen Haushaltspläne bis auf Spalten-Ebene, und CLAUDE.md enthält seit dem letzten Commit vier Arbeitsweise-Regeln, die das Verhalten des Agenten bei der Implementierung steuern. Der nächste Schritt ist der erste Parser-Code.
Für diesen Schritt braucht der Agent zwei Dinge: eine klar formulierte Aufgabe in Form eines Custom Skills, und einen TDD-Loop, der sicherstellt, dass das Ergebnis stimmt — nicht nur dass es läuft.
Was ein Custom Skill ist
Ein Custom Skill ist eine Markdown-Datei unter .claude/skills/<name>/SKILL.md. Das Frontmatter enthält name und description. Der Body beschreibt Ablauf, Werkzeuge und Verifikations-Kriterien.
Claude Code lädt den Skill automatisch, wenn die Situation passt. Welche Situation passt, entscheidet die description-Zeile.
Die Trigger-Description ist die wichtigste Zeile der ganzen Datei.
# Schlecht — zu allgemein:
description: Parst PDFs. Use when parsing PDFs.
Diese Description sagt nichts Verwertbares. Der Agent weiß nicht, wann „parsing PDFs" die Situation beschreibt — bei jedem Bash-Befehl mit einem PDF-Pfad? Beim Lesen einer Log-Datei? Der Skill löst entweder nie aus oder ständig.
# Gut — domänenspezifisch mit Trigger-Phrasen:
description: Extrahiert Titelübersicht-Daten aus einem bayerischen Haushaltsplan-PDF
(Einzelplan). Verwende diesen Skill wenn du einen Einzelplan parsen, Titel
extrahieren, JSON ausgeben oder Tests gegen Referenzwerte aus dem Abschluss prüfen
sollst. Trigger: "parse Epl", "extrahiere Titel", "run parser", "JSON für Epl".
Drei Elemente: was der Skill tut, wann er zutrifft, konkrete Phrasen als Aktivierungs-Muster. Das reicht für zuverlässiges Matching.
Den parse-haushalt-Skill schreiben
skill-creator ist der Meta-Skill für genau diese Aufgabe: Skills strukturieren, Descriptions schärfen, Evals schreiben. Wir haben ihn so aufgerufen:
Erstelle einen Skill für byhaushalt: parse-haushalt.
Der Skill ruft extract_titles() auf Einzelplan-PDFs auf und gibt JSON aus.
Domäne: bayerische Haushaltspläne, Titelübersicht-Seiten.
Verifikation: uv run pytest tests/ -v muss grün sein vor jedem Commit.
skill-creator hat daraufhin eine erste Skill-Datei mit generischer Description ausgegeben:
description: Parses Bavarian budget PDFs. Use when parsing PDFs or extracting data.
Der erste Praxistest zeigte das Problem: Claude Code rief den Skill auf, als wir fragten „lies die architecture.md nochmal durch". Die Description matcht zu weit. Zweiter skill-creator-Aufruf:
Verbessere die Trigger-Description von parse-haushalt.
Problem: der Skill hat ausgelöst bei "lies die Architektur-Doku" — das ist kein Parser-Auftrag.
Ziel: nur bei konkreten Parser-Aufgaben auslösen.
Domänen-Keywords: Einzelplan, Titelübersicht, Abschluss, JSON, extract_titles.
Trigger-Phrasen explizit auflisten.
Das Ergebnis ist die Version oben — mit Domänen-Keywords und expliziter Trigger-Phrasen-Liste. Skills sind keine statischen Dokumente, die man einmal schreibt und nie anfasst. Eine Description, die beim ersten Entwurf gut klingt, löst in der Praxis zu eng oder zu weit aus. skill-creator ist das Werkzeug für diese Iteration — beim Erstellen und beim Nachschärfen nach echten Einsätzen.
Skills sind keine statischen Dokumente, die man einmal schreibt und nie anfasst. Eine Description, die beim ersten Entwurf gut klingt, löst in der Praxis zu eng oder zu weit aus. skill-creator ist das Werkzeug für diese Iteration — beim Erstellen und beim Nachschärfen nach echten Einsätzen.
Die Verifikation steht im Skill-Body, nicht in CLAUDE.md. Der Grund: CLAUDE.md beschreibt allgemeine Konventionen für alle Aufgaben. Verifikation für den Parser ist domänenspezifisch — uv run pytest tests/ -v, Summen-Konsistenz, rote Tests bedeuten kein Commit. Das gehört in den Skill, der diese Aufgabe übernimmt.
uv-Setup
cd parser/
uv sync --extra dev
pyproject.toml legt den Stack fest, den wir in ADR 001 entschieden haben:
[project]
dependencies = ["pdfplumber>=0.11", "polars>=1.0"]
[project.optional-dependencies]
dev = ["pytest>=8.0", "hypothesis>=6.0"]
Ein uv sync auf einem frischen Checkout installiert genau diese Versionen aus uv.lock — ohne Netzwerk, in unter einer Sekunde für diesen Stack. Das ist der Reproduzierbarkeits-Vorteil aus ADR 001: uv sync plus uv run als einzige Voraussetzung.
TDD-Loop
Bevor pdf_extract.py existiert, schreiben wir die Tests — nicht Claude Code.
Der Grund liegt im Kontrollpunkt: Der Test ist die Spec. Wer die Kriterien bestimmt, entscheidet was „fertig" bedeutet. Wenn Claude Code Kriterien und Testcode alleine erarbeitet, definiert der Agent das Ziel selbst — und schreibt naturgemäß Tests, die zu seiner eigenen Implementierung passen. Das sind Bestätigungs-Tests, keine Fehlerfinder.
Den Testcode kann Claude schreiben — assert abs(total - Decimal("47379.0")) <= Decimal("1.0") ist Formulierungsarbeit. Aber das Kriterium dahinter — Gesamtausgaben 47.379,0 Tsd. €, Toleranz ±1 Tsd. €, entnommen aus der Abschluss-Seite (Seite 22) — kommt von uns. Claude Code kennt diesen Wert nicht aus dem Training.
Ohne das Kriterium von uns wäre Claude auf assert len(titles) > 0 verfallen — ein Test, der grün wird sobald irgendetwas zurückkommt, unabhängig ob die Zahlen stimmen.
Die Aufteilung in der Praxis: wir liefern die Erfolgskriterien und Referenzwerte, Claude schreibt den Testcode dagegen, Claude implementiert bis die Tests grün sind.
Sechs Tests, geordnet nach Abstraktionsebene:
def test_extract_titles_nonempty(epl11_titles):
assert len(epl11_titles) > 0
def test_every_titel_has_required_fields(epl11_titles):
required = {"titel_nr", "fkz", "kap_nr", "epl_nr", "zweckbestimmung", "soll_2026_tsd"}
for t in epl11_titles:
missing = required - set(vars(t).keys())
assert not missing
def test_all_titles_belong_to_epl11(epl11_titles):
wrong = [t for t in epl11_titles if t.epl_nr != "11"]
assert not wrong
def test_kapitel_nummern_sind_subset_der_bekannten(epl11_titles):
known_kap = {"11 01", "11 02", "11 04"}
found_kap = {t.kap_nr for t in epl11_titles}
assert not (found_kap - known_kap)
def test_gesamtausgaben_epl11_2026():
titles = extract_titles(EPL11)
ausgaben = [t for t in titles
if t.soll_2026_tsd is not None and not t.titel_nr.startswith("1")]
total = sum(t.soll_2026_tsd for t in ausgaben)
assert abs(total - Decimal("47379.0")) <= Decimal("1.0")
def test_gesamteinnahmen_epl11_2026():
titles = extract_titles(EPL11)
einnahmen = [t for t in titles
if t.soll_2026_tsd is not None and t.titel_nr.startswith("1")]
total = sum(t.soll_2026_tsd for t in einnahmen)
assert abs(total - Decimal("11.9")) <= Decimal("1.0")
Die Referenzwerte für die Summen-Tests kommen aus der Abschluss-Seite von Epl11.pdf (Seite 22). Architecture.md hat diese Seite kartiert. Wir entnehmen: Gesamtausgaben 2026 = 47.379,0 Tsd. €, Gesamteinnahmen 2026 = 11,9 Tsd. €.
So geht es zum Nachmachen
Schritt 1 — Tests schreiben lassen. Gib Claude Code im parser/-Verzeichnis diesen Prompt:
Schreib parser/tests/test_pdf_extract.py mit 6 Tests für extract_titles().
Kriterien:
- Rückgabe ist nicht leer
- Jeder Titel hat: titel_nr, fkz, kap_nr, epl_nr, zweckbestimmung, soll_2026_tsd
- epl_nr ist immer "11"
- Nur Kapitel 11 01, 11 02, 11 04
- Summe Ausgaben-Titel (Titelnummer beginnt nicht mit "1") ≈ 47379.0 Tsd. €, Toleranz ±1.0
- Summe Einnahmen-Titel (Titelnummer beginnt mit "1") ≈ 11.9 Tsd. €, Toleranz ±1.0
Kein Implementierungscode. Nur Tests. Die Funktion extract_titles heißt
extract_titles(pdf_path: Path) -> list[Titel].
Schritt 2 — Tests laufen lassen (müssen fehlschlagen):
cd parser/
uv sync --extra dev
uv run pytest tests/ -v
Erwartet: ImportError: No module named 'parser.pdf_extract' — korrekt, das Modul existiert noch nicht.
Schritt 3 — Implementation. Gib Claude Code diesen Prompt (nach dem roten Test):
Die 6 Tests liegen in parser/tests/test_pdf_extract.py — alle schlagen fehl mit ImportError.
Implementiere parser/src/parser/pdf_extract.py:
- Dataclass Titel mit den Feldern aus docs/architecture.md (Zielmodell-Sektion)
- Funktion extract_titles(pdf_path: Path) -> list[Titel]
- Parsst nur Titelübersicht-Seiten — Erläuterungen, Abschluss, Vorwort überspringen
- Wichtig: Spalte 6 enthält gestapelte A/B/C-Werte — nicht als soll_2026 einlesen
- Scope: nur Epl11. Kein Code über diesen Scope hinaus.
- Alle 6 Tests müssen grün sein vor dem Commit.
Schritt 4 — Tests laufen lassen (müssen grün sein):
uv run pytest tests/ -v
Erwartet: 6 passed in 0.22s
Vor der Implementation der Funktion:
ImportError: No module named 'parser.pdf_extract'
Korrekt. Das Modul existiert noch nicht. Das ist der Ausgangszustand von TDD: roter Test, klares Ziel.
Jetzt kommt der Prompt für die Implementation:
Die 6 Tests liegen in parser/tests/test_pdf_extract.py — alle schlagen fehl mit ImportError.
Implementiere parser/src/parser/pdf_extract.py:
- Dataclass Titel mit den Feldern aus docs/architecture.md (Zielmodell-Sektion)
- Funktion extract_titles(pdf_path: Path) -> list[Titel]
- Parsst nur Titelübersicht-Seiten — Erläuterungen, Abschluss, Vorwort überspringen
- Wichtig: Spalte 6 enthält gestapelte A/B/C-Werte (Soll 2025, Ist 2024, Ist 2023)
— diese dürfen nicht als soll_2026 eingelesen werden
- Scope: Epl11 (30 Seiten). Kein Code über diesen Scope hinaus.
- Alle 6 Tests müssen grün sein vor dem Commit — kein Commit vorher.
Der Verweis auf docs/architecture.md ist hier entscheidend. Der Agent weiß bereits, was Spalte 6 bedeutet — es steht im Repo. Ohne die kartierte Struktur aus Art. 2 wäre der Prompt länger und fehleranfälliger.
Nach der Implementation:
6 passed in 0.22s
Wie Tests Halluzinationen verhindern
Tests 5 und 6 sind die kritischen. Der Summen-Test zwingt den Parser, tatsächliche Werte aus dem PDF zu lesen — keine geschätzten, keine halluzinierten.
Der Titelübersicht-Block in Epl11.pdf hat sechs Spalten. Spalte 6 enthält drei gestapelte Vergleichswerte: A = Soll 2025, B = Ist 2024, C = Ist 2023 — vertikal, eine Zeile pro Wert, ohne eigene Titelnummer. Architecture.md dokumentiert dieses Muster. Eine naive Implementierung, die alle Dezimalzahlen in einer Textzeile sammelt, zieht diese Spalte-6-Werte als soll_2026 herein und liefert falsche Summen.
Ohne den Summen-Test: der Parser läuft durch, keine Exception, falsches Ergebnis. Mit dem Test: sofortige Identifikation.
Das ist der Unterschied zwischen Verification-before-Completion als Disziplin und Verification als optionalem Schritt. Die Arbeitsweise-Regel „Zielgetriebene Ausführung" aus CLAUDE.md macht das verbindlich: Erfolgskriterien vor der Implementation, nicht danach.
Erstes Roh-JSON
parser/output/epl11.json — 81 Titel, 26 KB:
{
"epl_nr": "11",
"epl_name": "Bayerischer Oberster Rechnungshof",
"haushaltsjahr": "2026",
"titel_count": 81,
"titel": [
{
"titel_nr": "111 01-0",
"fkz": "011",
"kap_nr": "11 01",
"epl_nr": "11",
"zweckbestimmung": "Gebühren, Beiträge, tarifliche und gebührenartige Entgelte",
"soll_2026_tsd": null
},
{
"titel_nr": "119 49-6",
"fkz": "011",
"kap_nr": "11 01",
"epl_nr": "11",
"zweckbestimmung": "Vermischte Einnahmen",
"soll_2026_tsd": 5.0
}
]
}
soll_2026_tsd: null steht für --- im PDF — kein Ansatz für diesen Titel im Haushaltsjahr. Was noch fehlt: historische A/B/C-Werte, Erläuterungstext, Verpflichtungsermächtigungen. Und: nur Epl11 aus 16 Einzelplänen. Das kommt in Artikel 4.
CLAUDE.md Arbeitsweise — konkrete Wirkung
Die vier Regeln waren beim Parser-Commit direkt sichtbar.
Minimum-Prinzip: Der erste Entwurf wollte alle 17 Einzelpläne auf einmal einlesen. Das wurde zurückgestellt. Epl11 (30 Seiten, kleinster Einzelplan) als erstes Target — 81 Titel, 6 Tests grün, klare Grundlage für die Erweiterung.
Vor dem Code: Annahmen explizit: Scope ist Epl11, Ziel sind Titelübersicht-Seiten, Verifikation ist Summen-Konsistenz gegen Abschluss-Seite. Das stand vor der ersten Implementierungszeile.
Chirurgische Änderungen: Der Parser überspringt Erläuterungsseiten, Abschlussseiten und Vorwort aktiv — nicht stillschweigend, sondern explizit durch Marker-Erkennung. Kein Code berührt, was der Skill nicht beschreibt.
Zielgetriebene Ausführung: Die Tests definieren das Ziel. Erst wenn 47379.0 ± 1.0 passt, gilt die Aufgabe als abgeschlossen — nicht wenn der Code keine Exception wirft.
Stand am Ende dieses Artikels
git clone https://codeberg.org/rotecodefraktion/byhaushalt.git
cd byhaushalt
git checkout v0.3
cd parser
uv sync --extra dev
uv run pytest tests/ -v
Vollständiger Stand unter byhaushalt @ v0.3.
Tag v0.3 markiert: erster lauffähiger Parser, 6 grüne Tests, 81 extrahierte Titel aus Epl11, Custom Skill mit erprobter Trigger-Description.
Wie es weitergeht
Der nächste Artikel behandelt Slash Commands — wiederverwendbare Workflows, die der Agent auf Zuruf ausführt. Für byhaushalt: ein /compile-epl-Command, der Extraktion, Validierung und JSON-Export für beliebige Einzelpläne in einem Schritt erledigt. Die Extraktion aus v0.3 wird dabei auf alle 16 Einzelpläne ausgeweitet.
Wie CLAUDE.md und Permissions das Fundament legen, zeigt Artikel 1. Die PDF-Strukturanalyse mit Plan Mode beschreibt Artikel 2.