Befund #4 aus dem Security-Audit (PII/LLM-Content im Container-Log):
Die letzten 10 print()-Aufrufe in app/{report,embeddings,parlamente}.py
durch strukturiertes Logging (logger.warning/exception/info) ersetzt.
Betroffen:
- report.py: 2× print in _append_original_antrag → logger.exception
- embeddings.py: 3× print in index_programm → logger.warning/info/exception
- parlamente.py: 5× print in NRWAdapter → logger.error/exception
logger.exception statt print+traceback: Stack-Trace wird automatisch
angehängt, ohne den LLM-Content oder Antrags-Details als Volltext zu
leaken (nur die Drucksache-ID als Kontext-Parameter).
Audit-Status nach diesem Commit: alle 7 adressierbaren Befunde aus #57
sind gefixt (1 Rate-Limit, 2/6 XSS/XXE, 3 Path-Traversal, 4 PII-Log,
5 CSRF via Auth, 7 Search-DoS). Befund 8 (Secrets als ENV) ist
akzeptiertes Risiko für Single-Server-Docker.
Tests: 201 passed, 5 skipped.
Bug 1 — NRW-Titel "Drucksache XX/YYYYY":
NRW's get_document machte nur HEAD-Request auf die PDF-URL und gab
title="Drucksache 18/18085" zurück — keinen echten Titel. Fix: nutzt
jetzt search(drucksache) um den echten Eintrag von OPAL zu holen.
Fallback: leerer Titel statt generischer, damit der LLM-Titel nicht
überschrieben wird. Plus _pick_best_title Helper: doc.title nur
übernehmen wenn es ein echter Titel ist (nicht "Drucksache XX").
Bug 2 — Nur Antragsteller im Passungsprofil, keine Regierungsfraktionen:
Der LLM ignorierte die "UND Regierungsfraktionen"-Anweisung im Prompt.
Fix: explizite PFLICHT-FRAKTIONEN-Zeile im User-Prompt:
"Du MUSST folgende Fraktionen in wahlprogrammScores bewerten: SPD, CDU, GRÜNE"
(dedupliziert aus fraktionen + regierungsfraktionen).
Tests: 194/194 grün.
Batch-Re-Analyse muss nochmal laufen mit den Fixes (21 bereits fertig,
15 noch offen — werden alle erneut benötigt weil die Titel/Fraktionen
in den neuen Assessments falsch sind).
Stub durch echten Adapter ersetzt. Recon + Implementierung in einem
Wurf, weil das Backend deutlich freundlicher ist als bei SL/NI:
- Server-side rendered HTML, keine SPA, keine Auth, keine Cookies
- TYPO3 mit ext-solr unter /parlament/dokumente/drucksachen
- Filter direkt als URL-Query-Params (q, dokumentenart, wahlperiodeid[],
sort, anzahl_treffer, page)
- 17.598 Drucksachen in WP19, davon ~10-15% Anträge — wir holen pro
Page 100 Hits, paginieren bis 3 Pages und filtern client-seitig auf
<p>Antrag …</p> (analog zu SL/HE)
Pattern-Extraktion über drei Regexen aus dem stabilen result-block:
<div class="row result">
<h4><a href="…pdf">Drucksache Nr. 19/<NR> vom DD.MM.YYYY</a></h4>
<p>Antrag <FRAKTION>[, <FRAKTION2>]</p>
<h5><strong>TITLE</strong></h5>
</div>
Drucksachen-Lookup: q=<drucksache> matched die Nummer im Volltext und
liefert sie als einzigen Hit — wie bei SL und HB, kein dedizierter
GetById-Endpoint nötig.
Smoke-Test im Container:
search("Schule", 5) → 5 Anträge in WP19 (SPD/FW-BAYERN+CSU/GRÜNE/AfD/AfD)
get_document(19/11388) → match
download_text(19/11388) → 4694 chars echter Antrags-Volltext
search("", 5) → 5 newest Anträge mit korrektem date-DESC sort
Free-Voters-Disambiguation funktioniert über den #55 Parteinamen-Mapper:
"FREIE WÄHLER" auf Bayerns Liste wird zu "FW-BAYERN" canonicalized
(separat von "FREIE WÄHLER" in RP und "BVB-FW" in BB).
Tests: 185/185 grün.
UI-Aktivierung erfolgt separat in #35 (blockiert auf diesem Commit
und auf den BY-WP19-Wahlprogrammen — CSU, GRÜNE, AfD, SPD, FDP, FW).
Refs: #23, #49 (Roadmap Phase 3)
Reverse-Engineering aus HAR-Capture (User-Browser, /suche?searchValue=Schule):
- Endpoint: POST /umbraco/aawSearchSurfaceController/SearchSurface/GetSearchResults/
- Content-Type: application/x-www-form-urlencoded; charset=UTF-8 mit rohem
JSON im Body (Kendo-Konvention von $.ajax ohne expliziten contentType)
- Body MUSS Sections={} und Sort={} als leere Dicts haben — sobald
Sections.Print/etc. gesetzt sind, antwortet der Server mit HTTP 500
(eigene Stunden in der Sackgasse, bis HAR den minimalen Body zeigte)
- Body-Schema: {Filter:{Periods:[17]}, Pageination:{Skip,Take}, Sections:{},
Sort:{}, OnlyTitle:false, Value:<query>, CurrentSearchTab:0}
Response-Mapping (FilteredResult[*]):
- DocumentNumber → drucksache (e.g. "17/11")
- Title → title
- DocumentType → typ; client-side gefiltert auf "Antrag" (Print-Section
enthält Anfragen + Anträge + Gesetzentwürfe gemischt, ~30-50% sind Anträge)
- Publisher (kollektive Anträge: "CDU"/"SPD") + DocumentAuthor
(individuelle MdL: "Name, Vorname (CDU);…") via parteien.extract_fraktionen
- PublicDate (ISO mit T-Suffix) → datum (auf 10 Zeichen abgeschnitten)
- FilePath: ``/file.ashx?FileId=…&FileName=…`` ist ein HTML-Iframe-Wrapper
(455 Bytes), nicht das PDF! Echter Binär-Endpoint ist
``/Downloadfile.ashx`` (Großbuchstabe!) mit denselben Query-Parametern.
Der Wrapper hat mich beim ersten Smoke-Test mit "no objects found"
angeschmissen, der Iframe-Hint im HTML hat den Trick verraten.
Drucksachen-Lookup nutzt ``Value=<drucksache>``: der Server matcht die
Nummer im Volltext und liefert sie zuverlässig als ersten Hit. Kein
dedizierter GetById-Endpoint vorhanden.
Smoke-Test gegen prod (im Container):
- search("Schule", limit=5) → 2 Anträge in WP17 (140 Print-Hits gesamt,
Antrag-Filter auf 2/140 — der Rest sind Anfragen/Gesetzentwürfe):
17/11 [CDU] "Schule als Lern- und Bildungsort weiter stärken …"
17/419 [AfD] "Eine gute Bildungspolitik als wesentlicher Bestandteil …"
- get_document("17/11") → match
- download_text("17/11") → 3520 chars echter Antrags-Volltext (Header,
Fraktion, Resolutionstext)
Tests: 185/185 grün (keine Regression).
UI-Aktivierung erfolgt separat in #31 (blockiert auf diesem Commit).
Refs: #19, #49 (Roadmap Phase 3)
Reaktiviert die in Phase J vertagte Adapter-Implementation: statt
ASP.NET-Postbacks zu simulieren (blockt durch __VIEWSTATE-Komplexität
plus robots.txt: Disallow: /), liest die neue ``SNEdasXmlAdapter``-
Klasse einen wöchentlich manuell aus EDAS exportierten XML-Dump.
Workflow:
1. User exportiert in der EDAS-Suchmaske mit Filter "Dokumententyp =
Antr" einen XML-Dump (bis zu 2500 Treffer/Export, sortiert
newest-first nach Datum)
2. Datei wird unter ``data/sn-edas-export.xml`` abgelegt (ins
persistent volume des prod-containers)
3. ``search()``/``get_document()`` lesen die XML-Datei lokal — keine
Server-Calls gegen edas.landtag.sachsen.de
4. ``download_text()`` resolved die echte PDF-URL on-demand über einen
einzelnen GET gegen ``viewer_navigation.aspx`` (single GET, kein
Postback) und holt dann das PDF von ``ws.landtag.sachsen.de/images``
XML-Schema (ISO-8859-1):
- ``<ID>`` interne EDAS-Doc-ID
- ``<Wahlperiode>``, ``<Dokumentenart>``, ``<Dokumentennummer>``
- ``<Fundstelle>`` z.B. ``"Antr CDU, BSW, SPD 01.10.2024 Drs 8/2"`` —
enthält Typ, Urheber und Datum, parsen via Regex
- ``<Titel>`` Volltext-Titel
PDF-URL-Schema (extrahiert aus dem viewer_navigation.aspx onLoad-
Handler): ``ws.landtag.sachsen.de/images/{wp}_Drs_{nr}_{...}.pdf``
mit variablen Suffix-Komponenten — wir machen die Resolution lazy.
Mapper-Erweiterung:
- ``parteien.PARTEIEN``-Tabelle um ``BÜNDNISGRÜNE``/``Bündnisgrüne``
ergänzt — der Sachsen-spezifische zusammengeschriebene Eigenname der
GRÜNEN-Fraktion (sonst wären 8/2100 etc. mit leerer Fraktionen-Liste
rausgekommen)
BL-Eintrag:
- ``SN.aktiv = True``
- ``doku_system="EDAS-XML-Export"`` (klare Klassifikation, dass es
KEIN normaler Webcrawler ist)
- Test ``test_sn_is_eigensystem_not_parldok`` umbenannt in
``test_sn_uses_xml_export_not_parldok``
Live-Probe lokal:
```
search('Klima', limit=5):
8/2100 2025-03-17 | [GRÜNE] | Fahrradoffensive Sachsen ...
7/192 2019-10-11 | [LINKE] | Erste Schritte zur Klimager...
7/2067 2020-03-19 | [CDU, SPD, GRÜNE] | Sächsische Waldbesitzer ...
```
176 Unit-Tests grün. Container braucht beim Deploy einen XML-Upload
ins data/-Volume — separater scp-Schritt.
Refs: #26, #38, #59 (Phase J revived)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Schließt #21 (HB-Scraper) und #33 (UI-Aktivierung). Eigenständige
``PARiSHBAdapter``-Klasse für paris.bremische-buergerschaft.de.
Backend (HAR-Trace TEMP/paris.bremische-buergerschaft.de.har):
- Single-POST gegen ``/starweb/paris/servlet.starweb`` mit
form-urlencoded Body
- ``path=paris/LISSHFL.web``, ``format=LISSH_BrowseVorgang_Report``
- ``01_LISSHFL_Themen=<query>`` (Volltext-Thesaurus)
- ``02_LISSHFL_PARL=S OR L`` (Stadt + Landtag in einem Rutsch)
- ``03_LISSHFL_WP=21`` (aktuelle Wahlperiode; Multi-WP-Range
timeout-t den Server bei 60s)
- Wildcards (``*``) timeout-en ebenfalls — bei leerer Query verwenden
wir das hochfrequente Stoppwort ``"der"`` als Catch-all
Hit-Format aus dem Single-Page-HTML:
- ``<tbody name="RecordRepeater"><tr name="Repeat_TYP">``
- Title in ``<h2><a>``
- ``Drs <b>21/730 S</b>`` mit S/L-Suffix für Stadtbürgerschaft vs
Landtag — Drucksachen-IDs werden als ``21/730S`` (ohne Space)
gespeichert
- ``Änderungsantrag vom 23.02.2026`` (Typ + Datum)
- Fraktionen-Liste nach ``<br/>``
- PDF-Link mit ``target="new"`` auf bremische-buergerschaft.de
Pipeline:
- ``search()`` mit client-side ``"antrag"``-Filter (analog #61),
fängt ``"Antrag"``, ``"Änderungsantrag"`` etc.
- ``get_document()`` linearer Lookup
- ``download_text()`` PDF-via-fitz
BL-Eintrag in ``bundeslaender.py``:
- ``HB.aktiv = True``
- ``doku_system="PARiS"`` (statt der alten Klassifikation "StarWeb" —
PARiS ist eine deutlich abweichende Servlet-Variante, kein eUI)
- ``drucksache_format="21/1234S"``
- Test ``test_hb_is_starweb_not_paris`` umbenannt in
``test_hb_is_paris_starweb_variant``, prüft jetzt auf "PARiS"
Live-Probe:
```
21/730S 2026-02-23 | [SPD,GRÜNE,LINKE] | Änderungsantrag | Haushaltsgesetze ...
21/1449 2025-11-05 | [SPD,GRÜNE,LINKE] | Antrag | Finanzierung der Bremischen Häfen
21/555S 2025-06-17 | [CDU] | Antrag | Clima-Campus zügig beantworten
```
176 Unit-Tests grün, Live-Verifikation Sub-A im Container nach Deploy.
Refs: #21, #33, #59 (Phase I)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Berlin-PARDOK ist von Schriftlichen Anfragen dominiert und liefert ohne
server-side ETYPF-Filter (BE: document_type=None) bei chunksize=100 nur
1-2 Anträge zurück. Damit reicht das Window selbst für limit=20 nicht
aus, um z.B. die A100-Antrag-Drucksache 19/2650 zu finden — und
get_document() liefert None.
Floor bewusst hoch auf 1500 angehoben (vorher 100/500). Bei einem
typischen Verhältnis 1:30 Antrag/Anfrage in BE liefert das ~50 Anträge,
genug für robuste Lookups in den letzten 24 Monaten.
176 Unit-Tests grün.
Refs: #61 Bug 5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BE-Adapter hat document_type=None (eigene ETYPF-Werte werden vom
Berliner PARDOK nicht akzeptiert), wodurch der Server alle Doku-Typen
zurückliefert. Das 200-Result-Window war damit vollständig von
'Schriftliche Anfrage'-Hits aushungernd, sodass Anträge wie 19/2650 nie
ans Frontend kamen — und get_document() für genau diese Drucksachen
None lieferte.
Patch: client-side 'antrag'-Substring-Filter läuft jetzt unabhängig
vom Server-Filter (vorher nur wenn document_type gesetzt war). BB/RP
und alle PortalaAdapter-Instanzen profitieren mit.
176 Unit-Tests grün, Live-Verifikation Sub-B im Container nach Deploy.
Refs: #61 Bug 5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drei aus #61 identifizierte Production-Bugs gefixt:
- **Bug 4 (BB Datum)**: BB.wahlperiode_start vom 2024-10-23 (Konstituie-
rende Sitzung) auf 2024-09-22 (Wahltag) zurückgesetzt. Damit fällt
die Geschäftsordnungs-Drucksache 8/2 vom 2024-10-17 in den
Plausibilitäts-Check. Ist auch semantisch sauberer — die WP fängt
mit der Wahl an, nicht mit der formalen Konstituierung.
- **Bug 2/3 (BB/RP Type-Filter leakt Kleine Anfrage / Beschluss-
empfehlung)**: Server-side ETYPF/DTYPF-Filter ist best-effort über
die portala-Instanzen — BB+RP lassen die nicht-Antrag-Typen durch.
Client-side strict-filter im PortalaAdapter.search() nach Aufruf von
_parse_hit_list_html: nur Hits, deren typ-String das Substring
"antrag" enthält, kommen weiter. Substring-Match (nicht exact),
damit "Antrag gemäß § 79 GO" und ähnliche Subtypen passieren.
176 Unit-Tests grün, Live-Verifikation via Sub-A im Container nach
Deploy.
Refs: #61 (Bug 2, 3, 4)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Live-Verifikation in der Container-DB hat aufgedeckt, dass TH ParlDok
für sehr frische Vorlagen (z.B. 8/1594, datum 2026-03-31, allowed=false)
``link``/``prelink`` als leeren String liefert — das PDF ist noch nicht
zur Veröffentlichung freigegeben.
Bisheriges Verhalten: Adapter konstruierte einen Drucksache-Eintrag mit
``link=''``, der dann durch die Pipeline rutschte und im Frontend als
unklickbarer Eintrag erschien. ``download_text()`` würde später an
``not doc.link`` scheitern, was die Analyse blockt.
Sauberer Skip an der Quelle: ``_hit_to_drucksache`` returnt None, wenn
weder ``link`` noch ``prelink`` einen Pfad liefern. Das ist konsistent
mit den anderen None-Returns für unbrauchbare Hits (kein lp, kein
number).
Lokal verifiziert: 176 Unit-Tests grün. Live-Verifikation gegen
Production folgt nach Deploy via Sub-A-Test im Container.
Refs: #61 (Bug 1: TH leerer Link)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Zentrale `app/parteien.py` als Single Source of Truth für die Partei-
Auflösung:
- `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen
bekannten Aliasen, optionalem `bundesland_scope` und Government-
Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW,
BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der
generische FREIE WÄHLER-Eintrag).
- `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups
mit Government-Vorrang und FW-Familien-Disambiguierung
- `extract_fraktionen(text, *, bundesland=None)` als Funnel für die
vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer-
partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope-
Filter (SSW nur in SH, BVB-FW nur in BB, etc.).
- `display_name(canonical, *, long=False)` für UI/PDF — kurze Form
bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN"
statt "GRÜNE" etc.
Adapter-Migration in `app/parlamente.py`:
- Vier nahezu identische `_normalize_fraktion()`-Methoden in
PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter
durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit
`self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt.
- `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen
für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`,
also keine Call-Site-Änderung nötig.
`app/embeddings.py:496` Workaround-Hack entfernt:
- `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen
`normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen
dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und
Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise
überall garantiert kanonisch.
Tests:
- Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW-
Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion,
Government-Marker, Tabellen-Konsistenz
- `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6
statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4
Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter
BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation
157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die
kanonischen Keys sind exakt die in der DB stehenden Strings, kein
Migrations-Schritt nötig.
Refs: #55, #59 (Phase B)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Riesige Überraschung aus dem BB-HAR-Trace: Brandenburg ist NICHT
StarWeb wie in dokukratie und bundeslaender.py klassifiziert,
sondern läuft auch auf dem portala/eUI-Backend. Endpoint
/portal/browse.tt.json mit db_id=lbb.lissh. Das alte
/starweb/LBB/ELVIS/-Frontend ist nur Legacy.
Folgeprobing offenbarte: RP/opal.rlp.de läuft ebenfalls portala
(db_id=rlp.lissh, 46759 hits in WP18), ebenso NI/HE/BB. Damit ist
Phase 2 großteils KEIN StarWeb-Adapter-Bau, sondern PortalaAdapter-
Wiederverwendung mit konfigurierbaren Parametern.
Activated via Registry-Einträge:
- "BB" → PortalaAdapter(base_url=parlamentsdokumentation.brandenburg.de,
db_id=lbb.lissh, wahlperiode=8). Nutzt die BE-Card-Variante des
Hit-Parsers (efxRecordRepeater).
- "RP" → PortalaAdapter(base_url=opal.rlp.de, db_id=rlp.lissh,
wahlperiode=18). NICHT mit dem NRW OPAL verwechseln — anderer
Markenname, andere Engine.
PortalaAdapter erweitert um zwei neue Konstruktor-Parameter mit
backward-kompatiblen Defaults:
- typ_filter: Optional[str] = "DOKDBE"
Wenn None, wird die TYP=<value>-Klausel weggelassen. Manche
Instanzen (HE/hlt.lis) lehnen DOKDBE ab.
- omit_date_filter: bool = False
Wenn True, wird der DAT/DDAT/SDAT-Term weggelassen. HE
und ähnliche Instanzen haben andere Date-Field-Namen.
Plus _parse_hit_list_cards Date-Regex erweitert: zusätzlich zum
"vom DD.MM.YYYY"-Pattern (BE) jetzt auch "DD.MM.YYYY"-plain
(BB schreibt Datum vor Drucksachen-Nummer ohne "vom"-Marker).
Smoke-Test (lokal):
BB q="": 5 hits in 5.9s
BB q="Schule": 5 hits (Pflegeschulen, Genderverbot, Hochschulen)
RP q="": 5 hits in 4.1s (Entlastung, Bildungschancen)
RP q="Schule": 5 hits (Hochschulbau, G9-Gymnasien, Leistungsgerechtigkeit)
bundeslaender.py: BB.doku_system "StarWeb"→"portala", RP analog,
beide aktiv=True. Anmerkungen mit dem portala-Verweis und der
Klarstellung "OPAL/RLP ≠ NRW OPAL" erweitert.
NICHT in diesem Commit:
- HE: portala-Backend (hlt.lis) ist erreichbar, aber das HE-Card-
Layout ist anders (Title direkt im <h3> statt <h3><span>, kein
<span class="h6"> für Meta) — eigener Parser-Pfad nötig, deferred.
- NI: nilas.niedersachsen.de/portal/ ist eine Login-Page, das
öffentliche Backend ist nicht zugänglich — deferred.
- HB: kein /portal/-Endpoint, bleibt das alte StarWeb-Servlet —
braucht eigenen HAR-Trace, deferred.
- BB als StarWeb-Template (#27) ist hinfällig, weil BB portala ist.
Phase 2 (3/6) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SH läuft auf der ältesten der vier Backend-Familien: Starfinder-CGI
auf lissh.lvn.parlanet.de. URL-basiert (nicht stateful wie das
moderne StarWeb-Servlet von BB/HE/NI/RP/HB), Latin-1-encoding,
flat HTML-Tabelle als Hit-Format. Eigener Adapter weil das Schema
fundamental anders ist als alles andere.
Endpoint:
http://lissh.lvn.parlanet.de/cgi-bin/starfinder/0
?path=lisshfl.txt&id=FASTLINK&pass=&search=WP=20+AND+dtyp=antrag
&format=WEBKURZFL
Hit-Format pro <tr class="tabcol*">:
<b>{TITLE}</b><br>
Antrag {URHEBER} {DD.MM.YYYY} Drucksache <a href="{PDF}">{N/M}</a>
Quelle: dokukratie/sh.yml + Live-Probing.
Encoding: Server liefert iso-8859-1 ohne korrektes Content-Type-
Header. Adapter dekodiert resp.content explizit als latin-1.
SSW-Detection im _normalize_fraktion: SH ist das einzige BL mit
SSW-Fraktion (von der 5%-Hürde befreit), pattern ist \\bSSW\\b
analog zu \\bAfD\\b.
Free-Text-Suche client-seitig (siehe #18) — server-side query-
syntax mit (term) im starfinder-search-Param wird vom Server nicht
als Volltext interpretiert, einheitlich mit allen anderen aktiven
Adaptern.
Smoke-Test (lokal):
SH q="": 8 hits in 14.4s
SH q="Schule": 8 hits in 14.8s (Schulentwicklung Westküste,
Hochschulen, queere Vielfalt an Schule etc.)
SH q="Klima": 8 hits (klimafreundlich, Klimafolgen,
Strategischer Aktionsplan)
SH q="Bildung": 8 hits (berufliche Bildung, Holocaust-Wissen)
bundeslaender.py::SH.aktiv = True. doku_base_url auf
lissh.lvn.parlanet.de korrigiert (ehemaliger landtag.ltsh.de-
Eintrag passte nicht zum echten Endpoint).
Damit ist Phase 2 (1/6) angefangen — als Nebenpfad, weil das
StarWeb-Servlet (#27 BB als Template für 5 weitere) ohne HAR-
Trace nicht sauber reverse-engineerbar war.
Phase 2 (1/6) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Thüringen läuft auf parldok.thueringer-landtag.de mit ParlDok 8.3.5
(J3S GmbH) — exakt dieselbe Version wie MV. Aber TH packt seine
Anträge unter zusammengesetzten type-Strings ("Antrag gemäß § 79 GO",
"Antrag gemäß § 74 (2) GO") und kind="Vorlage" statt der MV-Variante
kind="Drucksache"/type="Antrag". Strict-Match auf "Antrag" hat 0
Treffer geliefert.
Lösung: ParLDokAdapter um zwei Konstruktor-Parameter erweitert:
- document_typ_substring=True → Substring-Match auf type-Feld
("Antrag" matched "Antrag gemäß § 79 GO", "Alternativantrag" usw.)
- kinds=["Drucksache", "Vorlage"] → erweiterte kind-Liste
Defaults sind backward-kompatibel (Substring-Match aus, kinds nur
Drucksache), sodass MV und HH unverändert weiterlaufen.
_hit_matches_filters() als zentraler Filter-Helper extrahiert,
search() und get_document() nutzen ihn — get_document() überspringt
ihn allerdings, weil dort beliebige Drucksachen aufrufbar sein müssen,
unabhängig vom search-Time-Filter.
Hostname-Korrektur: parldok.thueringen.de redirected per 303 auf
parldok.thueringer-landtag.de. doku_base_url in bundeslaender.py
auf den neuen Host umgestellt.
Smoke-Test (lokal):
TH q="": 8 hits in 3.3s
TH q="Schule": 2 hits in 25.7s (Lernmittelbeschaffung, Modernisierung
Bund-Länder-Vereinbarung — beide Schul-bezogen)
TH q="Klima": 0 hits (keine in den letzten 1000 Drucksachen)
Damit ist Phase 1 (3/3) komplett. Nächstes Phase-2 Issue: #27 BB als
StarWebAdapter-Template.
Phase 1 (3/3) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hamburg's parldok runs ParlDok 8.3.1 (J3S GmbH) — kompatibel mit
der MV-Variante (8.3.5). Selber /parldok/Fulltext/Search-Endpoint,
selbe Body-Schema, selbes Hit-Format. Dadurch ist der existierende
ParLDokAdapter aus #4 ohne Code-Änderungen wiederverwendbar.
Eingetragen wurde nur:
- ADAPTERS["HH"] = ParLDokAdapter(base_url=buergerschaft-hh.de,
wahlperiode=23, prefix=/parldok, document_typ="Antrag")
- bundeslaender.py::HH.aktiv = True
Smoke-Test (lokal):
HH q="": 8 hits in 1.5s, jüngste WP23-Anträge sortiert newest-first
HH q="Schule": 1 hit in 13.2s (HH ist klein, WP23 erst seit März 2025,
HH nutzt eher "Kita"/"Bildung"/"Lehrkräfte" im Titel)
HH q="Klima": 2 hits
Verifikation HH ist 8.x:
curl https://www.buergerschaft-hh.de/parldok/ | grep generator
→ "ParlDok 8.3.1, entwickelt von der J3S GmbH"
Dies ist der zweite Phase-1-Win — ein nahezu kostenloser Adapter-
Reuse weil das Backend identisch ist. Anders als BW (#29), das eine
eigene PARLISAdapter-Klasse brauchte, braucht HH gar keinen neuen
Code.
Phase 1 (2/3) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PARLIS auf parlis.landtag-bw.de läuft technisch auf demselben
eUI-Backend wie LSA-PADOKA und BE-PARDOK, hat aber drei wichtige
Unterschiede, die eine eigene Klasse statt einer PortalaAdapter-
Subklasse rechtfertigen:
1. Body-Schema: minimales lines mit l1/l2/l3/l4 (statt LSA/BE
2/3/4/10/11/20.x/90.x), serverrecordname=vorgang,
format=suchergebnis-vorgang-full, sort=SORT01/D SORT02/D SORT03,
keine parsed/json-Felder. Quelle: dokukratie/scrapers/portala.query.bw.json
plus HAR-Verifikation gegen die Live-Instanz.
2. Async polling: die initiale SearchAndDisplay-Antwort liefert nur
search_id mit status=running, KEINE report_id. Erst eine zweite
SearchAndDisplay-Anfrage mit id=<search_id> (ohne search-Component)
bekommt nach 1-3 Sekunden die report_id zurück. Reverse-engineered
aus esearch-ui.main.js requestReportOK() Z. ~1268.
3. Hit-Format: report.tt.html liefert Records als JSON-in-HTML-Comments
<!--{"WMV33":[...],"EWBV22":[...],...}-->. Komplett anderes Format
als LSA Perl-Dump oder BE HTML-Cards. Felder:
- EWBV22: "Drucksache 17/10323"
- EWBD05: direkter PDF-URL
- WMV33: Schlagworte (joined by ;)
- WMV30: Urheber-Kurzform
- EWBV23: "Antrag <Urheber> <DD.MM.YYYY>"
Smoke-Test (lokal):
BW q='': 8 hits in 17s, jüngste WP17-Anträge mit Datum + Fraktion
BW q='Schule': 8 hits, alle wirklich Schul-bezogen (Hochschule, Grundschule,
Schwimmunterricht, Lehrerbedarf etc.)
BW q='Klima': 8 hits, Klimaschutz/CO2/Energieberatung
get_document(17/10323): roundtrip funktioniert
bundeslaender.py: aktiv=True für BW; Anmerkung erweitert mit
PARLISAdapter-Verweis und drei-Unterschiede-Hinweis für künftige
Wartung. Test test_four_active_bundeslaender umbenannt zu
test_active_bundeslaender_include_phase_1_set, prüft jetzt nur
Subset-Bedingung statt exakter Count, damit Phase-1/2-Erweiterungen
keine Test-Updates brauchen.
Phase 1 (1/3) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die
drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben
schon zwei weitere Bugs in Production-Code aufgedeckt.
## Setup
- requirements-dev.txt mit pytest + pytest-asyncio
- pytest.ini mit asyncio_mode=auto
- tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit
die Suite ohne den vollen prod-requirements-Satz läuft (pure unit
tests, kein PDF-Parsing, kein HTTP)
## Tests
- tests/test_parlamente.py (33 Tests)
* PortalaAdapter._parse_hit_list_cards: doctype/doctype_full
NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion-
/Datum/PDF-Extraktion gegen ein BE-Card-Fixture
* PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump-
Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü)
* PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen
Card- und Dump-Format
* PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes
inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW
* ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache
Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern,
Landesregierung-Detection
* ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred,
aber dokumentiert)
* ADAPTERS-Registry-Sanity
- tests/test_embeddings.py (11 Tests)
* _chunk_source_label: Programm-Name + Seite (Halluzinations-
Bug-Regression aus 1b5fd96)
* format_quotes_for_prompt: jeder Chunk muss Programm-Name
enthalten, strict-citation-Hinweis muss im Output sein,
keine NRW-Halluzinationen für MV/BE-Chunk-Sets
- tests/test_wahlprogramme.py (14 Tests)
* Registry-Struktur (jahr int, seiten int, .pdf-Endung)
* File-Existenz: jede registrierte PDF muss in
static/referenzen/ liegen — würde Tippfehler in den 22
indexierten Programmen sofort fangen
* embeddings.PROGRAMME-Konsistenz-Cross-Check
- tests/test_bundeslaender.py (15 Tests)
* Sanity über 16-State-Registry
* #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb,
SN=Eigensystem
* Wahltermine plausibel (zwischen 2026 und 2035)
- tests/test_analyzer.py (4 Tests)
* Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop
## Bug-Funde während der Test-Schreibphase
Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden
durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt:
1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten,
wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu
strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter.
2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched
"Ministerium der Finanzen" nicht als Landesregierung, weil
\bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei
MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix:
\bMINISTER ohne abschließendes \b.
Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen
Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen
in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"-
Befund aus dem MV-Smoke-Test in #4.
Phase 0 aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server-side facet_fulltext-Suche aus #12 war asymmetrisch zu BE/LSA
(beide nur Title-Filter über 730d-Window aus #13). User-Entscheidung
2026-04-08: einheitliches Verhalten ist wichtiger als das beste
Verhalten in 2 von 4 Adaptern.
Konkrete Änderungen:
- _build_search_body() schickt query nicht mehr server-side. Der
query-Parameter bleibt in der Signatur als unused-mit-del, weil
die Wieder-Aktivierung später ein Drop-in sein soll wenn die
PortalaAdapter-Variante reverse-engineered wurde.
- _initial_search() und _paginated_hits() ohne query-Parameter.
- search() macht clientseitigen Title+Urheber-Filter wie der
PortalaAdapter — same Codepfad, einheitliches Verhalten.
- get_document() nutzt die unveränderte Pagination.
- FACET_FULLTEXT-Konstante und _fulltext_id-Helper bleiben im Code
als Dokumentation für die spätere Re-Aktivierung. Im Docstring
ist die Tag-Form festgehalten.
Folgen:
- MV "Schule" ist von 20 (mit Volltext) auf 3 zurück (Title-Filter
über die letzten 1000 Drucksachen). Gleiches Niveau wie BE/LSA
pre-#13.
- Browse-Mode (no query) ist unverändert: ~10 hits in ~25s, MAX_PAGES=10.
- Wenn das später nicht reicht: #16 (UI-Split DB vs. Landtag) und
ein optionaler "echter Volltext"-Toggle (#17 closed-as-deferred)
bleiben als Folge-Optionen.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Real server-side fulltext search through the eUI sf-Index requires
reverse-engineering the LSA/BE-specific search field (the obvious
candidates VOLL, VOLL.main, WEV62 and bare-term-without-sf all
return zero hits when probed). Without browser DevTools to capture
a real fulltext request that's a multi-hour project — split out
to remain in #13 as a follow-up.
This commit ships the pragmatic interim fix from #11:
- BE date_window_days: 180 → 730
Berlin had a tight default window because PARDOK has ~10x more
documents than PADOKA. With the bigger window the client-side
title/Urheber filter reaches back across most of WP19 instead
of just the last six months.
- chunksize logic in PortalaAdapter.search() inverted from
"small when query, big when no query" to the opposite. The
query-filtered path now pulls up to max(limit*10, 500) records
per page so the title-filter has enough material; the unfiltered
browse path stays at max(limit*2, 100).
- httpx timeout 30s → 60s. LSA's report.tt.html occasionally
takes 30+s on cold start; warm requests are <10s.
Smoke test (local):
BE Schule: 15 hits (was 0)
LSA Schule: 14 hits (was N/A; same path)
Live verification follows after deploy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the client-side title/Urheber substring filter with a
real server-side full-text search through ParlDok's facet_fulltext
tag (type=0). The tag schema is reverse-engineered from
pd.addInput in the live bundle.js:
{"type": 0,
"id": <getFulltextId(term)>, # non-alphanum → "-"
"fulltext": <raw term>,
"label": <raw term>,
"field": "Alle"} # search all indexed fields
The Resultpage queryid inherits the fulltext filter, so
pagination works without re-sending the tag.
Smoke test (local):
Schule → 10 hits (was 3)
Klima → 10 hits across multiple parties + dates
Wohnen → 10 hits including older 2025 Anträge
The 10-page (1000-doc) safety bound still applies on top of the
fulltext-filtered result set, but since the server now narrows
to ~2k Schule-related docs WP-wide instead of the 8k+ raw WP
total, the bound is no longer the limiting factor for typical
queries.
Closes#12. BE/LSA equivalent (#13) is independent — eUI
sf-index names still need DevTools tracing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new ParLDokAdapter for ParlDok 8.x parliament documentation
systems by J3S GmbH. MV becomes the fourth supported state alongside
NRW, LSA and BE.
Notable details:
- ParlDok 8.x is a single-page app whose backend is a JSON API rooted
at {base}/parldok/Fulltext/{Search,Resultpage}. The legacy ParLDok
5.x HTML POST form (parldok/formalkriterien) used by dokukratie's
mv.yml has been deprecated by the LandtagMV upgrade to 8.3.5 and
is no longer reachable via the old form fields — hence a new
adapter rather than reusing the dokukratie scraper.
- Two-stage pagination: Fulltext/Search returns the first 100 hits
+ a queryid; further pages come from Fulltext/Resultpage with
{queryid, limit:{Start,Length}}. The Search endpoint silently
ignores any non-zero Start, so single-stage offset pagination is
not an option.
- Server-side filter via facet_lp (type=10) on the configured WP;
type=Antrag is filtered client-side because the facet_type value
IDs are instance-specific and would require an extra
Fulltext/Filter discovery call. ParlDok also returns the same
Drucksache multiple times when it appears in several
Vorgänge/Beratungen, so search() dedupes by lp/number.
- Wahlprogramme zur LTW 26.09.2021 are not yet indexed (follow-up
in #4) — analyses run with the federal Grundsatzprogramm fallback,
same as Berlin until #10 lands.
Drive-by cleanup of PortalaAdapter print() statements: switched to
the module-level logger so adapter parser bugs no longer disappear
into stdout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_parse_hit_list_cards referenced an undefined `doctype` instead of
`doctype_full` on the query-filter path. The surrounding try/except in
search() swallowed the exception, so Berlin queries silently returned
0 hits whenever a search term was given.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PortalaAdapter is now parameterizable and serves both LSA and Berlin
from a single class. Berlin is activated as the third live bundesland
(after NRW + LSA), with the deliberate caveat that the LTW 2023
Wahlprogramme are not yet indexed.
PortalaAdapter refactor
- Class attributes (bundesland, name, base_url, db_id, wahlperiode)
moved into the constructor. New optional parameters:
- portala_path: "/portal" for LSA, "/portala" for Berlin
- document_type: "Antrag" for LSA, None for Berlin (BE's ETYPF
index uses different value strings; the document_type subtree
is dropped from the action.search.json tree)
- pdf_url_prefix: "/files/" by default; absolute URLs in the hit
list are passed through unchanged (Berlin embeds full
starweb/adis/citat/... links)
- date_window_days: 730 for LSA, 180 for BE (BE has ~10x more
documents per WP, narrower window keeps payloads bounded)
- _build_search_body builds the JSON tree dynamically: when
document_type is None, the entire ETYPF/DTYPF/DART subtree is
omitted, mirrored in the parsed/sref display strings as well.
- _parse_hit_list_html now auto-detects between two formats:
1. LSA-style: <pre>$VAR1 = …</pre> Perl Data::Dumper records
(existing parser, untouched).
2. Berlin-style: production HTML cards with efxRecordRepeater
divs, h3 titles, h6 metadata lines containing the document
type, drucksachen-id and date, plus a direct <a href="…pdf">
to the PDF on the same host.
- Berlin extracts originator parties from the h6 line ("Antrag CDU,
SPD" → ["CDU","SPD"], typ "Antrag") via the new word-boundary
_normalize_fraktion regex.
- _normalize_fraktion rewritten with regex word boundaries, fixing a
long-standing bug where comma-separated fraction lists like
"CDU, SPD" failed to match CDU. Also picks up BSW for the
Brombeer/SPD-BSW landtage and "Senat von Berlin" as Landesregierung.
bundeslaender.py
- BE flipped to aktiv=True. anmerkung documents the Wahlprogramm-
Lücke and the auto-detected hit-list format.
Live verified against pardok.parlament-berlin.de:
- WP 19 with 180-day date window returns 2962 hits, page 1 contains
5 records all with title, drucksache, date, PDF URL.
- 19/3107 ("Kleingewässerprogramm") correctly extracted as Antrag of
CDU+SPD; 19/3104-3106 as Vorlagen zur Beschlussfassung; 19/3108 as
Vorlage zur Kenntnisnahme.
- LSA still returns the same 5 current Anträge of März 2026 — no
regression from the refactor.
Known limitation (will be tracked as a follow-up issue)
- Berlin Wahlprogramme zur LTW 2023 are not yet indexed in the
embeddings DB. The 2023 PDFs are no longer linked from the live
party websites (which currently feature 2026 draft programmes), and
Wayback has no snapshots. The analyzer therefore falls back to
bundesländer-übergreifende Grundsatzprogramme for BE Anträge until
the 2023 PDFs are sourced manually.
Refs #3.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a clean-room PortalaAdapter that talks to the eUI/portala framework
behind PADOKA (Landtag Sachsen-Anhalt). Same engine powers Berlin's
PARDOK; the same adapter will serve issue #3 once activated for BE.
Reverse-engineering notes
- The "PADOKA = StarWeb" assumption from issue #1 / dokukratie's st.yml
is outdated. The Sachsen-Anhalt portal was migrated to the same
eUI/portala SPA framework Berlin uses. The legacy starweb URL returns
503; the new entry point is /portal/browse.tt.html.
- Search workflow is two-stage:
1. POST /portal/browse.tt.json with a JSON action body containing an
Elasticsearch-style query tree under search.json. Returns a
report_id plus hit count.
2. POST /portal/report.tt.html with {report_id, start, chunksize}
returns the HTML hit list. Each record carries a Perl Data::Dumper
block in a <pre> tag with the canonical metadata.
- The query schema (sources, search.lines, search.json tree, report
block) is taken from dokukratie/scrapers/portala.query.json (GPL-3.0)
— only structure/selectors are reused, no Python code is ported.
- DB id is "lsa.lissh"; the server validates this and rejects unknown
interfaces with an explicit errormsg.
- PDFs live under /files/drs/wp{N}/drs/d{nr}{xxx}.pdf and are served
directly without any session cookie.
What the adapter does
- search() builds a date-window query (last ~24 months) for "Antrag"
document type and returns the most recent hits. The user's free-text
query is applied as a client-side title/Urheber filter (no fulltext
search server-side yet — see "Limitations" below).
- Hits are parsed from the Perl record dumps in the report HTML:
- WEV06.main → title (Perl \x{xx} hex escapes decoded)
- WEV32.5 → relative PDF path
- WEV32.main → "Antrag <Urheber> <DD.MM.YYYY> Drucksache <b>X/YYYY</b>"
- Fraktion strings are normalised to canonical codes (CDU, SPD, GRÜNE,
FDP, AfD, LINKE, Landesregierung).
- get_document() looks up a single Drucksache by re-running the search.
- download_text() fetches the PDF and extracts text via PyMuPDF.
- bundeslaender.py: LSA's doku_system corrected from "StarWeb" to
"PARDOK", anmerkung updated with the migration story.
Limitations (deliberate, MVP)
- No server-side full-text search. The portala framework's sf index
names for LSA full-text content are not yet known; tree mutations
with sf=alAB return 0 hits. Client-side filter is "good enough" for
the next ~24 months of Anträge (≈few hundred per WP).
- LSA is still aktiv=False in bundeslaender.py — the adapter is dormant
in production until issue #2's wahlprogramm ingest and frontend
activation land.
Verified live against padoka.landtag.sachsen-anhalt.de:
- search(query="", limit=5) returned 5 current Anträge from März 2026
(LINKE + GRÜNE) with correct dates, fractions, titles and PDF URLs.
- download_text("8/6790") returned 5051 chars of real Antragstext
("ICE-Halt für Salzwedel dauerhaft erhalten").
Refs #2.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>