feat(#126): protokoll_parsers/-Sub-Package + Registry-Pattern + ADR 0009

Architektur-Refactor zur Vorbereitung BL-uebergreifender Parser:

- app/protokoll_parser_nrw.py → app/protokoll_parsers/nrw.py
- app/ingest_votes_nrw.py → app/ingest_votes.py (BL-uebergreifend)
- Neue app/protokoll_parsers/__init__.py mit:
  - PROTOKOLL_PARSERS-Dict (BL-Code → Parser-Funktion, derzeit nur NRW)
  - parse_protocol(bundesland, pdf_path) als BL-uebergreifender Einstieg
  - supported_bundeslaender()-Helper
  - NotImplementedError mit hilfreicher Message bei unbekanntem BL

CLI bekommt --supported-Flag fuer BL-Discovery:
  python -m app.ingest_votes --supported  → 'NRW'

ADR 0009 dokumentiert das Muster (Sub-Package + Funktions-Registry,
analog zu ADR 0002 fuer ParlamentAdapter). Folge-BL bekommen je
eine eigene Datei und einen Eintrag in PROTOKOLL_PARSERS — kein
Refactoring der Bestands-Logik.

Tests:
- 7 neue Tests in test_protokoll_parsers.py fuer Registry und Dispatch
- Bestehende NRW-Tests umbenannt zu test_protokoll_parsers_nrw.py,
  Imports angepasst — keine Verhaltens-Aenderung
- Bestehende Ingest-Tests umbenannt zu test_ingest_votes.py

642 Tests gruen, kein Verhaltens-Drift.
This commit is contained in:
Dotty Dotter 2026-04-28 08:37:31 +02:00
parent a9f0b61c75
commit 7de4df1fef
9 changed files with 333 additions and 44 deletions

View File

@ -262,7 +262,7 @@ async def init_db():
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen (#106). # Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen (#106).
# Granularitaet: "GRUENE und SPD haben zugestimmt", nicht pro MP — das # Granularitaet: "GRUENE und SPD haben zugestimmt", nicht pro MP — das
# ist der Datentyp, der aus deterministischen Parsern wie # ist der Datentyp, der aus deterministischen Parsern wie
# protokoll_parser_nrw.py rauskommt. # app/protokoll_parsers/ rauskommt.
# Compound-PK ueber quelle_protokoll, weil eine Drucksache mehrfach # Compound-PK ueber quelle_protokoll, weil eine Drucksache mehrfach
# abgestimmt werden kann (Ausschuss-Empfehlung + Plenum-Beschluss). # abgestimmt werden kann (Ausschuss-Empfehlung + Plenum-Beschluss).
await db.execute(""" await db.execute("""
@ -1211,7 +1211,7 @@ async def get_monitoring_new_today(scan_date: str) -> list[dict]:
# ─── Plenum-Vote-Results (#106) ───────────────────────────────────────────── # ─── Plenum-Vote-Results (#106) ─────────────────────────────────────────────
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen. # Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen.
# Quelle: protokoll_parser_nrw.py (NRW). BL-uebergreifender Parser ist #126. # Quelle: app/protokoll_parsers/ (NRW). BL-uebergreifender Parser ist #126.
async def upsert_plenum_vote( async def upsert_plenum_vote(
*, *,

View File

@ -1,16 +1,19 @@
"""Ingest-CLI fuer NRW-Plenarprotokolle (#106). """BL-uebergreifende Ingest-CLI fuer Plenarprotokolle (#106 / #126).
Pipeline: Pipeline:
1. PDF laden (Pfad oder URL) 1. PDF laden (Pfad oder URL)
2. protokoll_parser_nrw.parse_protocol() liefert Liste von Abstimmungen 2. ``protokoll_parsers.parse_protocol(bundesland, pdf_path)`` waehlt den
3. upsert_plenum_vote() schreibt jede Abstimmung in die DB BL-spezifischen Parser aus der Registry
3. ``upsert_plenum_vote()`` schreibt jede Abstimmung in die DB
CLI: CLI:
python -m app.ingest_votes_nrw --pdf /pfad/zu/MMP18-119.pdf python -m app.ingest_votes --pdf MMP18-119.pdf
python -m app.ingest_votes_nrw --url https://landtag.nrw.de/.../MMP18-119.pdf python -m app.ingest_votes --url https://landtag.nrw.de/.../MMP18-119.pdf
python -m app.ingest_votes_nrw --pdf MMP18-119.pdf --protokoll-id MMP18-119 python -m app.ingest_votes --pdf x.pdf --bundesland NRW --protokoll-id MMP18-119
python -m app.ingest_votes --supported # Liste der BL mit Parser
Die Protokoll-ID wird, wenn nicht uebergeben, aus dem Datei-Stem abgeleitet. Aktuell registriert: NRW. Folge-BL via app/protokoll_parsers/<bl>.py + Eintrag
in PROTOKOLL_PARSERS siehe ADR 0009.
""" """
from __future__ import annotations from __future__ import annotations
@ -23,7 +26,7 @@ import urllib.request
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from .protokoll_parser_nrw import parse_protocol from .protokoll_parsers import parse_protocol, supported_bundeslaender
from .database import upsert_plenum_vote from .database import upsert_plenum_vote
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -52,13 +55,17 @@ async def ingest_pdf(
protokoll_id: Optional[str] = None, protokoll_id: Optional[str] = None,
quelle_url: Optional[str] = None, quelle_url: Optional[str] = None,
) -> dict: ) -> dict:
"""Parse das PDF und schreibe alle gefundenen Abstimmungen in die DB. """Parse das PDF mit dem BL-Parser und schreibe alle Abstimmungen in die DB.
Returns: Returns:
Statistik-Dict ``{parsed, written, skipped_no_drucksache, errors}``. Statistik-Dict ``{parsed, written, skipped_no_drucksache, errors,
protokoll_id, bundesland}``.
Raises:
NotImplementedError: wenn fuer ``bundesland`` kein Parser registriert ist.
""" """
pid = protokoll_id or _derive_protokoll_id(pdf_path) pid = protokoll_id or _derive_protokoll_id(pdf_path)
parsed = parse_protocol(str(pdf_path)) parsed = parse_protocol(bundesland, str(pdf_path))
written = 0 written = 0
skipped_no_ds = 0 skipped_no_ds = 0
@ -100,17 +107,27 @@ def _cli() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Plenarprotokoll → plenum_vote_results-Tabelle (#106)", description="Plenarprotokoll → plenum_vote_results (#106 / #126)",
) )
src = parser.add_mutually_exclusive_group(required=True) src = parser.add_mutually_exclusive_group(required=False)
src.add_argument("--pdf", help="Pfad zu lokalem PDF") src.add_argument("--pdf", help="Pfad zu lokalem PDF")
src.add_argument("--url", help="HTTP(S)-URL zum PDF") src.add_argument("--url", help="HTTP(S)-URL zum PDF")
parser.add_argument("--bundesland", default="NRW", parser.add_argument("--bundesland", default="NRW",
help="Bundesland-Code (default: NRW)") help="Bundesland-Code (default: NRW)")
parser.add_argument("--protokoll-id", parser.add_argument("--protokoll-id",
help="Protokoll-ID (default: aus Datei-Stem)") help="Protokoll-ID (default: aus Datei-Stem)")
parser.add_argument("--supported", action="store_true",
help="Liste alle BL-Codes mit registriertem Parser")
args = parser.parse_args() args = parser.parse_args()
if args.supported:
for bl in supported_bundeslaender():
print(bl)
sys.exit(0)
if not args.pdf and not args.url:
parser.error("--pdf oder --url ist erforderlich")
if args.url: if args.url:
# Download in tmp und nach dem Run wieder loeschen # Download in tmp und nach dem Run wieder loeschen
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:

View File

@ -0,0 +1,69 @@
"""BL-uebergreifende Plenarprotokoll-Abstimmungsparser (#126).
Architektur (vgl. ADR 0009): pro Bundesland eine Modul-Datei
``app/protokoll_parsers/<bl-code>.py``, die mindestens eine Funktion
``parse_protocol(pdf_path: str) -> list[dict]`` exportiert. Die Registry
``PROTOKOLL_PARSERS`` mappt BL-Code Parser-Funktion.
Erwartetes Result-Schema pro Eintrag in der Liste::
{
"drucksache": str | None, # z.B. "18/1234"; None bei nicht aufloesbar
"ergebnis": str, # angenommen | abgelehnt | ueberwiesen | ...
"einstimmig": bool, # explizit als einstimmig markiert
"kind": str, # parser-intern, fuer Debug
"votes": { # fraktions-Listen pro Vote-Kategorie
"ja": list[str],
"nein": list[str],
"enthaltung": list[str],
},
}
NRW ist die Referenz-Implementierung. Folge-BL (HE/BB/MV/BE/...) bekommen
eigene Module mit demselben Funktions-Vertrag neue Eintraege in der
Registry sind reine Tippelarbeit, das Reverse-Engineering pro Landtag
ist die eigentliche Arbeit.
"""
from __future__ import annotations
from typing import Callable
from .nrw import parse_protocol as _parse_nrw
# Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal.
ProtokollParser = Callable[[str], list[dict]]
PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
"NRW": _parse_nrw,
}
def parse_protocol(bundesland: str, pdf_path: str) -> list[dict]:
"""BL-uebergreifender Einstieg. Sucht den Parser in der Registry.
Raises:
NotImplementedError: wenn fuer das Bundesland (noch) kein Parser
registriert ist. Folge-Issue: BL-Adapter ergaenzen mit einem
eigenen Modul plus Eintrag hier.
"""
parser = PROTOKOLL_PARSERS.get(bundesland)
if parser is None:
supported = ", ".join(sorted(PROTOKOLL_PARSERS)) or "(keine)"
raise NotImplementedError(
f"Kein Plenarprotokoll-Parser fuer {bundesland!r}. "
f"Unterstuetzt: {supported}. Siehe #126."
)
return parser(pdf_path)
def supported_bundeslaender() -> list[str]:
"""Liste der BL-Codes mit registrierten Parsern."""
return sorted(PROTOKOLL_PARSERS)
__all__ = [
"ProtokollParser",
"PROTOKOLL_PARSERS",
"parse_protocol",
"supported_bundeslaender",
]

View File

@ -0,0 +1,127 @@
# 0009 — Plenarprotokoll-Parser-Registry pro Bundesland
| | |
|---|---|
| **Status** | accepted |
| **Datum** | 2026-04-28 |
| **Refs** | #106, #126, ADR 0002 (Adapter-Pattern) |
## Kontext
Der NRW-Plenarprotokoll-Parser (#106) ist deterministisch, anchor-basiert
und erreicht 19/19 auf der MMP18-119-Fixture. Damit war die Architektur-Frage
gelöst — aber nur fuer NRW. Andere Bundeslaender publizieren ihre
Plenarprotokolle in fundamental anderen Formaten:
- Hessen: HTML mit semantischen Tags pro Beschluss
- Brandenburg: PDF mit Tabellen-Layout fuer Vote-Counts
- Mecklenburg-Vorpommern: ParLDok-XML-Export
- Berlin: PDF mit eigenem Formularkasten-Schema
- ...
Ein einziger Parser fuer alle BL ist nicht baubar. Die Reverse-Engineering-
Arbeit pro Landtag ist substantiell und passiert phasenweise: zuerst NRW
wegen der hohen Antragsdichte, danach BL fuer BL nach Bedarf.
Das Adapter-Pattern aus ADR 0002 (`ParlamentAdapter`) hat dieses Problem
fuer die Antrags-Suche bereits geloest. Plenarprotokoll-Parser ist die
naechste Familie mit derselben Form: pro BL eine eigene Implementierung,
ein gemeinsamer Aufruf-Vertrag, ein Registry-Lookup.
## Optionen
### Option A — Eine grosse Datei mit If-Else-Dispatch
Eine einzige `app/protokoll_parser.py`-Datei mit einem `parse_protocol(bl, pdf)`,
das je nach BL andere Funktionen ruft. **Vorteile:** flach, einfach.
**Nachteile:** waechst zur 2000-LOC-Datei, BL-spezifische Reverse-Engineering-
Notizen und Helper-Functions vermischen sich, schlechte Test-Isolation.
### Option B — OOP-Hierarchie mit `ProtokollParserBase` als ABC
Abstrakte Basisklasse mit `parse(pdf_path) -> list[VoteResult]`,
konkrete Subklassen pro BL. **Vorteile:** typisierter Vertrag.
**Nachteile:** Boilerplate fuer Klassen-Definitionen ohne Mehrwert,
weil der NRW-Parser keine Instanz-State hat (alles `def`-Funktionen,
keine `self.x`).
### Option C — Sub-Package mit Funktions-Registry (gewaehlt)
`app/protokoll_parsers/` als Sub-Package, pro BL eine eigene Datei
(`nrw.py`, `mv.py`, `he.py`, ...) die mindestens
`parse_protocol(pdf_path: str) -> list[dict]` exportiert. Eine
`PROTOKOLL_PARSERS`-Dict in `__init__.py` mappt BL-Code → Funktion.
Das BL-uebergreifende `parse_protocol(bl, pdf_path)` macht den Lookup.
**Vorteile:**
- Konsistent mit dem `ADAPTERS`-Dict in `parlamente.py` (ADR 0002)
- BL-Code lebt in eigener Datei mit eigenen Helpern und Notizen
- Neue BL = neue Datei + ein Eintrag in `__init__.py`, kein Refactoring
- Tests pro BL in eigener Test-Datei (`tests/test_protokoll_parsers_<bl>.py`)
- Parser-Funktionen bleiben simpel, kein OOP-Overhead
**Nachteile:**
- Vertrag ist nur per Convention dokumentiert (nicht via Type-System
erzwingbar) — dafuer ein Schema-Test in `test_protokoll_parsers.py`
als Sicherheitsnetz.
## Entscheidung
**Option C.** Konkret:
```
app/protokoll_parsers/
├── __init__.py # Registry + parse_protocol(bl, pdf) + supported_bundeslaender()
├── nrw.py # NRW v5 (vorher app/protokoll_parser_nrw.py)
└── <bl>.py # je BL eine Datei, sobald implementiert
```
**Vertrag fuer jeden Parser** (verbindlich):
```python
def parse_protocol(pdf_path: str) -> list[dict]:
"""Returns: [
{
"drucksache": str | None,
"ergebnis": str, # angenommen/abgelehnt/ueberwiesen/...
"einstimmig": bool,
"kind": str, # parser-intern, fuer Debug
"votes": {
"ja": list[str], # Fraktions-Codes (CDU, SPD, GRUENE, ...)
"nein": list[str],
"enthaltung": list[str],
},
},
...
]"""
```
**Naming:** Datei-Stem = lowercase BL-Code (`nrw.py`, `mv.py`, ...).
Registry-Key = uppercase BL-Code (`"NRW"`, `"MV"`).
**Konsumenten** rufen `parse_protocol(bundesland, pdf_path)` aus dem
Sub-Package, nicht direkt eine BL-Datei.
## Konsequenzen
### Positiv
- Folge-BL-Implementierungen ohne Refactoring der Bestands-Logik.
- Reverse-Engineering-Notizen leben pro BL in einer Datei statt verteilt
ueber eine Mega-Datei.
- Der `supported_bundeslaender()`-Helper macht in CLI und UI sofort
sichtbar, wo Daten verfuegbar sind und wo nicht.
- Neue Adapter-Test-Files folgen demselben Schema (`test_protokoll_parsers_<bl>.py`).
### Negativ
- Schema-Vertrag nur per Convention (kein TypedDict). Dafuer ein
Smoke-Test in `tests/test_protokoll_parsers.py`, der pro registriertem
Parser die Result-Keys pruefen wird, sobald >1 Implementation existiert.
### Folgen fuer andere ADRs
- ADR 0002 (Adapter-Pattern) bleibt gueltig; dieses ADR ueberbruckt es
nicht, sondern wendet das gleiche Muster auf eine zweite Adapter-Familie an.
- Folge-Issues (HE/BB/MV/BE/...) sind reine Implementation-Tickets ohne
Architektur-Diskussion — der Vertrag ist hier festgelegt.

View File

@ -25,6 +25,7 @@ und Konsequenzen. Format inspiriert von [Michael Nygard](https://cognitect.com/b
| [0006](0006-embedding-model-migration-v3-to-v4.md) | Embedding-Modell-Migration text-embedding-v3 → v4 | accepted | 2026-04-11 | | [0006](0006-embedding-model-migration-v3-to-v4.md) | Embedding-Modell-Migration text-embedding-v3 → v4 | accepted | 2026-04-11 |
| [0007](0007-test-taxonomy.md) | Test-Taxonomie (Unit / Integration / E2E / Property / Smoke) | accepted | 2026-04-28 | | [0007](0007-test-taxonomy.md) | Test-Taxonomie (Unit / Integration / E2E / Property / Smoke) | accepted | 2026-04-28 |
| [0008](0008-ddd-lightweight-migration.md) | DDD-Lightweight-Migration (Repository, LLM-Port, Domain-Verhalten) | accepted | 2026-04-20 | | [0008](0008-ddd-lightweight-migration.md) | DDD-Lightweight-Migration (Repository, LLM-Port, Domain-Verhalten) | accepted | 2026-04-20 |
| [0009](0009-protokoll-parser-registry.md) | Plenarprotokoll-Parser-Registry pro Bundesland | accepted | 2026-04-28 |
## Wann ADR, wann nicht ## Wann ADR, wann nicht

View File

@ -1,4 +1,4 @@
"""Tests fuer app/ingest_votes_nrw.py — PDF → plenum_vote_results Pipeline (#106).""" """Tests fuer app/ingest_votes.py — PDF → plenum_vote_results Pipeline (#106 / #126)."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -63,7 +63,7 @@ def _fake_parse_result(drucksache: str, ergebnis: str = "angenommen",
class TestIngestPdf: class TestIngestPdf:
def test_writes_each_parsed_vote(self, initialized_db, tmp_path): def test_writes_each_parsed_vote(self, initialized_db, tmp_path):
from app import ingest_votes_nrw, database from app import ingest_votes, database
fake_pdf = tmp_path / "MMP18-119.pdf" fake_pdf = tmp_path / "MMP18-119.pdf"
fake_pdf.write_bytes(b"%PDF-1.4 fake") fake_pdf.write_bytes(b"%PDF-1.4 fake")
@ -72,8 +72,8 @@ class TestIngestPdf:
_fake_parse_result("18/200", "abgelehnt", ja=["AfD"], nein=["CDU", "SPD"]), _fake_parse_result("18/200", "abgelehnt", ja=["AfD"], nein=["CDU", "SPD"]),
] ]
with patch("app.ingest_votes_nrw.parse_protocol", return_value=parser_results): with patch("app.ingest_votes.parse_protocol", return_value=parser_results):
stats = run(ingest_votes_nrw.ingest_pdf(fake_pdf)) stats = run(ingest_votes.ingest_pdf(fake_pdf))
assert stats["parsed"] == 2 assert stats["parsed"] == 2
assert stats["written"] == 2 assert stats["written"] == 2
@ -86,7 +86,7 @@ class TestIngestPdf:
def test_skips_entries_without_drucksache(self, initialized_db, tmp_path): def test_skips_entries_without_drucksache(self, initialized_db, tmp_path):
"""Anchors ohne aufloesbare Drucksache werden gezaehlt aber nicht """Anchors ohne aufloesbare Drucksache werden gezaehlt aber nicht
geschrieben (sonst muellt der Import die DB voll).""" geschrieben (sonst muellt der Import die DB voll)."""
from app import ingest_votes_nrw from app import ingest_votes
fake_pdf = tmp_path / "MMP18-50.pdf" fake_pdf = tmp_path / "MMP18-50.pdf"
fake_pdf.write_bytes(b"%PDF") fake_pdf.write_bytes(b"%PDF")
@ -94,31 +94,31 @@ class TestIngestPdf:
_fake_parse_result("18/300", "angenommen"), _fake_parse_result("18/300", "angenommen"),
{"drucksache": None, "ergebnis": "angenommen", "votes": {"ja": [], "nein": [], "enthaltung": []}}, {"drucksache": None, "ergebnis": "angenommen", "votes": {"ja": [], "nein": [], "enthaltung": []}},
] ]
with patch("app.ingest_votes_nrw.parse_protocol", return_value=parser_results): with patch("app.ingest_votes.parse_protocol", return_value=parser_results):
stats = run(ingest_votes_nrw.ingest_pdf(fake_pdf)) stats = run(ingest_votes.ingest_pdf(fake_pdf))
assert stats["parsed"] == 2 assert stats["parsed"] == 2
assert stats["written"] == 1 assert stats["written"] == 1
assert stats["skipped_no_drucksache"] == 1 assert stats["skipped_no_drucksache"] == 1
def test_protokoll_id_default_from_stem(self, initialized_db, tmp_path): def test_protokoll_id_default_from_stem(self, initialized_db, tmp_path):
from app import ingest_votes_nrw, database from app import ingest_votes, database
fake_pdf = tmp_path / "MMP18-77.pdf" fake_pdf = tmp_path / "MMP18-77.pdf"
fake_pdf.write_bytes(b"%PDF") fake_pdf.write_bytes(b"%PDF")
with patch("app.ingest_votes_nrw.parse_protocol", with patch("app.ingest_votes.parse_protocol",
return_value=[_fake_parse_result("18/500")]): return_value=[_fake_parse_result("18/500")]):
stats = run(ingest_votes_nrw.ingest_pdf(fake_pdf)) stats = run(ingest_votes.ingest_pdf(fake_pdf))
assert stats["protokoll_id"] == "MMP18-77" assert stats["protokoll_id"] == "MMP18-77"
votes = run(database.get_plenum_votes("NRW", "18/500")) votes = run(database.get_plenum_votes("NRW", "18/500"))
assert votes[0]["quelle_protokoll"] == "MMP18-77" assert votes[0]["quelle_protokoll"] == "MMP18-77"
def test_protokoll_id_override(self, initialized_db, tmp_path): def test_protokoll_id_override(self, initialized_db, tmp_path):
from app import ingest_votes_nrw, database from app import ingest_votes, database
fake_pdf = tmp_path / "scan.pdf" fake_pdf = tmp_path / "scan.pdf"
fake_pdf.write_bytes(b"%PDF") fake_pdf.write_bytes(b"%PDF")
with patch("app.ingest_votes_nrw.parse_protocol", with patch("app.ingest_votes.parse_protocol",
return_value=[_fake_parse_result("18/600")]): return_value=[_fake_parse_result("18/600")]):
run(ingest_votes_nrw.ingest_pdf( run(ingest_votes.ingest_pdf(
fake_pdf, protokoll_id="MMP18-99", quelle_url="https://example.com/x.pdf", fake_pdf, protokoll_id="MMP18-99", quelle_url="https://example.com/x.pdf",
)) ))
votes = run(database.get_plenum_votes("NRW", "18/600")) votes = run(database.get_plenum_votes("NRW", "18/600"))
@ -127,12 +127,12 @@ class TestIngestPdf:
def test_bundesland_override(self, initialized_db, tmp_path): def test_bundesland_override(self, initialized_db, tmp_path):
"""Adapter fuer andere BL koennten denselben Ingest-Helper nutzen.""" """Adapter fuer andere BL koennten denselben Ingest-Helper nutzen."""
from app import ingest_votes_nrw, database from app import ingest_votes, database
fake_pdf = tmp_path / "MV-MP1.pdf" fake_pdf = tmp_path / "MV-MP1.pdf"
fake_pdf.write_bytes(b"%PDF") fake_pdf.write_bytes(b"%PDF")
with patch("app.ingest_votes_nrw.parse_protocol", with patch("app.ingest_votes.parse_protocol",
return_value=[_fake_parse_result("8/100")]): return_value=[_fake_parse_result("8/100")]):
run(ingest_votes_nrw.ingest_pdf(fake_pdf, bundesland="MV")) run(ingest_votes.ingest_pdf(fake_pdf, bundesland="MV"))
# Lookup unter dem richtigen BL # Lookup unter dem richtigen BL
votes_mv = run(database.get_plenum_votes("MV", "8/100")) votes_mv = run(database.get_plenum_votes("MV", "8/100"))
assert len(votes_mv) == 1 assert len(votes_mv) == 1
@ -142,17 +142,17 @@ class TestIngestPdf:
def test_re_ingest_overwrites_same_protokoll(self, initialized_db, tmp_path): def test_re_ingest_overwrites_same_protokoll(self, initialized_db, tmp_path):
"""Erneuter Ingest desselben Protokolls aktualisiert die Eintraege """Erneuter Ingest desselben Protokolls aktualisiert die Eintraege
(idempotent), kein Duplikat.""" (idempotent), kein Duplikat."""
from app import ingest_votes_nrw, database from app import ingest_votes, database
fake_pdf = tmp_path / "MMP18-1.pdf" fake_pdf = tmp_path / "MMP18-1.pdf"
fake_pdf.write_bytes(b"%PDF") fake_pdf.write_bytes(b"%PDF")
with patch("app.ingest_votes_nrw.parse_protocol", with patch("app.ingest_votes.parse_protocol",
return_value=[_fake_parse_result("18/700", "angenommen", ja=["CDU"])]): return_value=[_fake_parse_result("18/700", "angenommen", ja=["CDU"])]):
run(ingest_votes_nrw.ingest_pdf(fake_pdf)) run(ingest_votes.ingest_pdf(fake_pdf))
# Re-Ingest mit korrigiertem Ergebnis (z.B. Parser-Fix) # Re-Ingest mit korrigiertem Ergebnis (z.B. Parser-Fix)
with patch("app.ingest_votes_nrw.parse_protocol", with patch("app.ingest_votes.parse_protocol",
return_value=[_fake_parse_result("18/700", "abgelehnt", ja=[], nein=["CDU"])]): return_value=[_fake_parse_result("18/700", "abgelehnt", ja=[], nein=["CDU"])]):
run(ingest_votes_nrw.ingest_pdf(fake_pdf)) run(ingest_votes.ingest_pdf(fake_pdf))
votes = run(database.get_plenum_votes("NRW", "18/700")) votes = run(database.get_plenum_votes("NRW", "18/700"))
assert len(votes) == 1 assert len(votes) == 1

View File

@ -0,0 +1,74 @@
"""Tests fuer app/protokoll_parsers/__init__.py — Registry + Dispatch (#126)."""
from __future__ import annotations
import pytest
from app.protokoll_parsers import (
PROTOKOLL_PARSERS,
parse_protocol,
supported_bundeslaender,
)
class TestRegistry:
def test_nrw_registered(self):
"""NRW ist die Referenz-Implementierung — muss da sein."""
assert "NRW" in PROTOKOLL_PARSERS
def test_supported_includes_nrw(self):
assert "NRW" in supported_bundeslaender()
def test_supported_returns_sorted(self):
codes = supported_bundeslaender()
assert codes == sorted(codes)
def test_registry_values_are_callable(self):
for code, parser in PROTOKOLL_PARSERS.items():
assert callable(parser), f"Parser fuer {code} ist nicht callable"
class TestDispatch:
def test_unknown_bl_raises_not_implemented(self):
with pytest.raises(NotImplementedError) as exc:
parse_protocol("XX", "/dev/null")
msg = str(exc.value)
assert "XX" in msg
# Liste der unterstuetzten BL muss in der Message stehen
assert "NRW" in msg
# Issue-Referenz fuer Folge-Arbeit
assert "#126" in msg
def test_known_bl_delegates_to_registered_parser(self, tmp_path, monkeypatch):
"""parse_protocol delegiert an den BL-Parser aus der Registry."""
called_with: list[str] = []
def fake_parser(pdf_path: str) -> list[dict]:
called_with.append(pdf_path)
return [{"drucksache": "18/1", "ergebnis": "angenommen", "votes": {"ja": [], "nein": [], "enthaltung": []}}]
# Temporaer einen TEST-Parser registrieren, dann wieder entfernen
monkeypatch.setitem(PROTOKOLL_PARSERS, "TEST", fake_parser)
result = parse_protocol("TEST", str(tmp_path / "x.pdf"))
assert called_with == [str(tmp_path / "x.pdf")]
assert len(result) == 1
assert result[0]["drucksache"] == "18/1"
class TestParserSchema:
"""Vertrag: jeder registrierte Parser muss Result-Dicts mit minimalem
Schema liefern drucksache (str|None), ergebnis (str), votes (dict)."""
def test_nrw_result_dict_has_expected_keys(self):
"""Smoke-Test mit handgemachtem Plenarprotokoll-Snippet — pruefen,
dass das Schema des Output-Dicts die in __init__.py dokumentierten
Keys enthaelt."""
from app.protokoll_parsers.nrw import find_results
text = "Damit ist der Antrag Drucksache 18/100 angenommen."
results = find_results(text)
assert results, "find_results sollte mindestens einen Treffer liefern"
for r in results:
for key in ("drucksache", "ergebnis", "kind", "einstimmig"):
assert key in r, f"Key '{key}' fehlt im Result"

View File

@ -1,12 +1,13 @@
"""Tests fuer app/protokoll_parser_nrw.py — NRW-Plenarprotokoll-Parser v5. """Tests fuer app/protokoll_parsers/nrw.py — NRW-Plenarprotokoll-Parser v5.
Backfill aus #134. Der Parser ist deterministisch und anchor-basiert; Backfill aus #134, BL-Refactor aus #126.
jede Aenderung an den RESULT_ANCHORS oder den Vote-Block-Regexes muss
sofort durch diese Tests fallen.
Die echte 19/19-Garantie auf MMP18-119 laeuft separat als Integration-Test Der Parser ist deterministisch und anchor-basiert; jede Aenderung an den
(braucht das PDF). Hier: pure-string-Tests fuer alle Reverse-Engineering- RESULT_ANCHORS oder den Vote-Block-Regexes muss sofort durch diese Tests
Findings, die bei der iterativen Entwicklung 1-15 dokumentiert wurden. fallen. Die echte 19/19-Garantie auf MMP18-119 laeuft separat als
Integration-Test (braucht das PDF). Hier: pure-string-Tests fuer alle
Reverse-Engineering-Findings, die bei der iterativen Entwicklung 1-15
dokumentiert wurden.
""" """
from __future__ import annotations from __future__ import annotations
@ -15,7 +16,7 @@ import types
# fitz ist via tests/conftest.py gestubbed — Pure-String-Funktionen kommen ohne aus. # fitz ist via tests/conftest.py gestubbed — Pure-String-Funktionen kommen ohne aus.
from app.protokoll_parser_nrw import ( from app.protokoll_parsers.nrw import (
normalize_fraktionen, normalize_fraktionen,
find_results, find_results,
resolve_drucksache_for_ueber, resolve_drucksache_for_ueber,