Commit Graph

91 Commits

Author SHA1 Message Date
Dotty Dotter
bf5400ae33 test(programme): Drift-Schutz zwischen WAHLPROGRAMME und PROGRAMME
ADR 0013 hatte als offene Folge "Doppelter Daten-Bestand zwischen
WAHLPROGRAMME und embeddings.PROGRAMME ist nicht aufgelöst — Risk:
stille Drift". Der invasive Compat-Shim (#222) ist aufwendig; bis
dahin fängt eine neue Test-Klasse die Drift bidirektional ab:

TestWahlprogrammeProgrammeConsistency (4 Tests):
- Jeder WAHLPROGRAMME-Eintrag hat ein passendes aktuelles Programm in
  PROGRAMME (sonst liefert aktuelles_wahlprogramm None)
- pdf-Dateinamen müssen übereinstimmen (file == pdf)
- Partei-Kurzform muss übereinstimmen
- Jedes aktuelle Wahlprogramm in PROGRAMME muss auch in WAHLPROGRAMME
  registriert sein (orphan-check andere Richtung)

Drift-Funde dabei:
- BIW (Bürger in Wut) HB war in PROGRAMME (biw-hb-2023, biw-hb-2019,
  biw-hb-2015), aber NICHT in WAHLPROGRAMME-HB. Bewertungs-Pipeline
  hätte BIW-Anträge gegen kein Wahlprogramm geprüft. Eintrag ergänzt:
  BÜRGER IN WUT — Programm Bürgerschaftswahl 2023 (26 Seiten).
- Test test_hb_has_four_parteien → test_hb_has_five_parteien.

92/92 Programme-Tests grün.
2026-05-08 14:18:41 +02:00
Dotty Dotter
18ea326e43 feat: Block 2.4 + 2.5 — historische Wahlprogramme zurück bis 2011
92 neue PDFs + Geltungsdaten in PROGRAMME-Registry:

Block 2.4 — direkte 2019er-Lücken (21 Dokumente):
- BIW Bremen: Wahlprogramme 2015 + 2019 + 2023
- BUND WP19 (BTW 2017): CDU, CSU, SPD, GRÜNE, FDP, AfD, LINKE
- HH WP21 (BS 2015): SPD, CDU, GRÜNE, LINKE, AfD, FDP
- TH WP6 (LTW 2014): LINKE, CDU, SPD, GRÜNE, AfD, FDP

Block 2.5 — Vor-Vorperioden 2011-2014 (~71 Dokumente) für 15 BL:
BB WP6, BE WP17, BUND WP18, BW WP15, BY WP17, HB WP19, HE WP19,
LSA WP6, MV WP6, NI WP17, NRW WP16, RP WP16, SH WP18, SL WP15, SN WP6.
Inkl. PIRATEN, BVB/FREIE WÄHLER, FREIE WÄHLER, SSW.

Coverage: 287 Programme (286 indiziert auf dev — linke-sl-2012 ist
ein 2-Seiten-Kurzwahlprogramm und liefert keine Chunks).

Source-of-Truth-Pflege:
- wahlprogramm-links.yaml ergänzt (Quellen-URLs für alle PDFs)
- wahlprogramm-shas.lock.json ergänzt (SHA-256 für Integritätsprüfung)
- test_programme.py: drei Versionen NRW CDU statt zwei

Schließt #233 + #234.
2026-05-08 14:10:52 +02:00
Dotty Dotter
e04eb6d340 feat: Block 2.2 — BUND WP20 (BTW 2021, Scholz-Ampel) historisch indiziert
7 Wahlprogramme zur BTW 26.09.2021 — die Programme der Scholz-Ampel-Periode
(SPD+GRÜNE+FDP, vereidigt 08.12.2021, vorgezogenes Ende 25.03.2025):

- cdu-bund-2021 (gemeinsam CDU/CSU "Stabilitaet und Erneuerung", 140 S., 232 chunks)
- csu-bund-2021 (eigenstaendige CSU-Bayern-Fokus-Variante, 18 S., 24 chunks)
- spd-bund-2021 (Zukunftsprogramm "Aus Respekt vor Deiner Zukunft", 66 S., 105 chunks)
- gruene-bund-2021 (272 S. barrierefreie Fassung, 269 chunks)
- fdp-bund-2021 (Beschluss 14.-16.05.2021 Berlin, 68 S., 136 chunks)
- afd-bund-2021 ("Deutschland. Aber normal.", 210 S., 160 chunks)
- linke-bund-2021 ("Zeit zu handeln!", 168 S., 324 chunks)

Total: 1.250 Chunks.

Geltungszeitraum 2021-09-26 (Wahltag) bis 2025-02-23 (Wahltag BTW 2025,
exklusiv). Antraege aus dieser Periode bekommen jetzt automatisch das
korrekte Programm zurueckgeliefert via wahlprogramm_zum_zeitpunkt():

- 2024-01-01 BUND/SPD -> spd-bund-2021 (Scholz-Ampel)
- 2025-02-22 BUND/SPD -> spd-bund-2021 (noch alt)
- 2025-02-23 BUND/SPD -> spd-bund-2025 (BTW-Wahltag, Wechsel)

Tests: 117 gruen, plus neue test_bund_2024_returns_btw_2021 und
test_bund_grenze_btw_2021_btw_2025.

Block 2.2 abgeschlossen — Block 2 Roadmap (16 BL × 3 WPs) ist 2/16 BL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:25:51 +02:00
Dotty Dotter
445fcc90ca feat: Block 2.1 — NRW WP17 historische Wahlprogramme indiziert (Pilot)
5 Programme zur LTW NRW 14.05.2017 als historische Wahlprogramme im
Embeddings-Index — erster Datensatz für die zeitpunktige Bewertung
historischer Antraege:

- cdu-nrw-2017 (Laschet, 120 S., 172 chunks)
- spd-nrw-2017 (Kraft, 116 S., 169 chunks)
- gruene-nrw-2017 (131 S., 322 chunks)
- fdp-nrw-2017 (Lindner, 56 S., 92 chunks)
- afd-nrw-2017 (84 S., 78 chunks)

Geltungszeitraum 2017-05-14 (Wahltag WP17) bis 2022-05-15 (Wahltag
WP18, exklusiv). Eintraege liegen NUR in embeddings.PROGRAMME — die
WAHLPROGRAMME[NRW]-Struktur bleibt single-current (cdu-nrw-2022).

programme._migrate_from_legacy hat einen neuen Schritt 2b, der
typ=wahlprogramm-Eintraege aus embeddings.PROGRAMME mit explizitem
gueltig_ab/_bis als historische Wahlprogramme registriert. Damit
liefert wahlprogramm_zum_zeitpunkt() jetzt fuer NRW-Antraege aus dem
Zeitraum 2017-2022 das passende Programm.

Live-Verifikation auf gwoe-antragspruefer-dev:
- 2018-09-01 -> cdu-nrw-2017 (WP17)
- 2024-01-01 -> cdu-nrw-2022 (WP18)
- Grenze: 14.05.2022 -> WP17, 15.05.2022 -> WP18

Tests: 116 gruen, plus neue test_grenze_zwischen_wp17_und_wp18 und
angepasstes test_datum_vor_aktueller_wp_nrw_wp17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:44:26 +02:00
Dotty Dotter
b003cc1d6d feat(programme): Wahlprogramm-Geltung beginnt am Wahltag (B1+B2)
Antwort auf B1 + B2 aus der Roadmap:
- B1: Antrag VOR Regierungsbildung (z.B. NRW WP18-Antrag im Mai 2022,
  vor Vereidigung Wuest II am 29.06.2022) bekommt jetzt das passende
  Wahlprogramm zurueck — der Geltungsbeginn ist der Wahltag, nicht die
  Vereidigung.
- B2: Opposition vs. Regierung wird einheitlich behandelt. Die fruehere
  Logik "Geltung ab Regierungsbildung" war fuer Regierungsfraktionen
  intuitiv (Koalitionsvertrag wird zu Politik), fuer Opposition aber
  willkuerlich. Programme werden zur Wahl beschlossen und sind
  Wahlversprechen ab dem Tag der Wahl.

Implementation in programme._migrate_from_legacy:
- gueltig_ab = aktuelle_legislatur(bl)["wahltermin"] (Fallback auf
  altes "regierungsbildung" fuer rueckwaerts-kompatible Eintraege)
- ``wahl``-Feld auf Wahltag gesetzt
- ``wp``-Feld aus aktuelle_legislatur ergaenzt

Das ``regierungsbildung``-Feld in WAHLPROGRAMME bleibt erhalten und
versorgt den Bewertungs-Kontext-Block weiterhin mit dem Anzeige-Wert
"Regierung zur Antragszeit" (per legislaturen.regierung_zum_zeitpunkt
laeuft das primaer ueber legislaturen.REGIERUNGEN).

UI-Effekt: im Antrag-Detail liest sich z.B. ein BUND-Eintrag jetzt
"gueltig seit 2025-02-23, 60 S." (BTW-Wahltag) statt "2025-05-06"
(Vereidigung Merz I).

Tests: 115 gruen (test_programme + test_legislaturen + test_wahlprogramme
+ test_embeddings). Tests test_bund_btw_2025_in_uebergangsphase und
test_bund_btw_2025_vor_wahl neu, decken die geaenderte Geltungs-Logik
explizit ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:17:08 +02:00
Dotty Dotter
1cf249353e test: 26 Tests fuer app.programme — Helper-API + Migration
Direkte Test-Abdeckung der zentralen Programm-Registry. Vorher nur
indirekt via test_legislaturen.py (das nicht aus programme.py importiert)
und ueber den Antrag-Detail-Smoke-Test.

Geprueft:
- Migration aus WAHLPROGRAMME + embeddings.PROGRAMME (>80 Eintraege,
  alle Pflichtfelder, eindeutige IDs, alle drei Typen vertreten)
- aktuelles_wahlprogramm: NRW/CDU → 2022, BUND/CDU → BTW 2025; XX → None
- wahlprogramm_zum_zeitpunkt: 2024 in NRW gibt cdu-nrw-2022; vor 2025-05-06
  in BUND gibt None (BTW-2025-Programme gelten erst ab Regierungsbildung)
- grundsatzprogramm_zum_zeitpunkt: BL-Fallback (CDU NRW → cdu-grundsatz-nrw,
  CDU HE → cdu-grundsatz Bund-Fallback); SSW/SH; CSU 2023
- parteien_mit_wahlprogramm: NRW=5, BUND=8
- alle_versionen, get_programm, Edge-Cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:07:59 +02:00
Dotty Dotter
4e7f7dac25 chore: konsolidiere Working-Tree mit dev-Stand nach Nextcloud-Sync-Resolution
Mehrtaegiger Sync-Stillstand hatte ueber 50 conflicted-copy-Dateien im
Working-Tree erzeugt. Die jeweils neuere Version wurde basierend auf
md5-Hash-Vergleich zum laufenden gwoe-antragspruefer-dev-Container
eingespielt.

Konsolidiert (38 modifiziert):
- analyzer.py, auswertungen.py, auth.py, config.py, database.py,
  drucksache_typen.py, embeddings.py, main.py, models.py, parlamente.py,
  ports/llm_bewerter.py, presse_generator.py, redline_utils.py, report.py,
  validators.py, wahlprogramm_fetch.py, wahlprogramm-links.yaml,
  wahlprogramm-shas.lock.json
- v2-Templates: base, components/{icon, matrix_mini, queue_widget,
  result_row}, screens/{admin_queue, admin_stand, aktuelle-themen,
  antrag_detail, auswertungen, cluster, landtag_suche, merkliste,
  methodik, tags}, static/v2/v2.css
- Tests: test_embeddings (Strict-Mode-Drop in reconstruct_zitate),
  test_endpoints_smoke, test_presse_generator, test_report,
  test_wahlprogramme (mit TestRegierungsbildung-Block, +120 LOC)
- docker-compose.dev.yml, docs/adr/index.md, docs/reference/api.md, mkdocs.yml

Neuzugaenge:
- app/marker.py, app/pm_render.py — Konsistenz-Marker, PM-Render-Adapter
- app/templates/v2/screens/scorecard{,_portrait,_werkstatt}.html — Cloud-Design-Scorecard
- app/static/v3/, app/templates/v3/ — v3-Layout-Hierarchie
- docs/adr/0010-stimmverhalten-gwoe-aggregat.md
- docs/adr/0011-aktuelle-themen-pm-generator.md
- docs/adr/0012-debug-auth-token-bypass.md
- scripts/{auto-rate-orphans, pm-quality-audit, pm-sample-bundle, rotate-debug-token}.sh
- tests/e2e/test_smoke_browser.py, tests/test_{auto_rate_runs, icons,
  marker, pm_render, presse_generator_style, thread_splitter,
  v2_pdf_consistency}.py

Plus inhaltlich uebernommen aus dem Conflict-Stand:
- embeddings.py: fw-by-2023.partei korrigiert von "FW" zu "FREIE WAEHLER"
  (war Mismatch zu wahlprogramme.py)
- embeddings.py: detailliertere Naming der BTW-2025-Wahlprogramme

Test-Suite-Stand: 1209 passed, 73 skipped (4 pre-existing failures in
test_presse_generator_style.py + 1 collection error in
integration/test_citations_substring.py — beide nicht durch dieses
Konsolidierungs-Commit verursacht).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:19:41 +02:00
Dotty Dotter
b5d2bb2515 feat: regierungsbildung+regierungsende in WAHLPROGRAMME, gueltig_ab in Grundsatzprogrammen
WAHLPROGRAMME erweitert:
- Pflichtfelder regierungsbildung (Vereidigung Kabinett) + regierungsende
  (None=laufend) pro Wahlprogramm. Geltung beginnt mit Regierungs-
  bildung, NICHT Wahltag.
- 6 fehlende Bundeslaender ergaenzt: BY, HB, HE, NI, SL, SN.
- BUND BTW-2025: 8 Wahlprogramme (CDU, CSU, SPD, GRUENE, FDP, AfD, LINKE,
  BSW) ersetzen die bisherigen Grundsatzprogramm-Eintraege. Vereidigung
  Merz I 2025-05-06.
- Helper regierungsbildung_for(), regierungsende_for(), regierung_aktuell().

embeddings.PROGRAMME erweitert:
- 6 Grundsatzprogramme (CDU 2024, SPD 2007, GRUENE 2020, FDP 2012,
  AfD 2016, LINKE 2011) tragen jetzt Beschluss-Datum als gueltig_ab,
  gueltig_bis=None.
- 8 BTW-2025-Wahlprogramme als Indexer-Eintraege.
- FDP-Programm-Name auf den korrekten "Karlsruher Freiheitsthesen 2012"
  umgestellt (vorher generisch "FDP Grundsatzprogramm 2012").

Tests: 71 grün (test_wahlprogramme + test_embeddings + test_legislaturen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:18:33 +02:00
Dotty Dotter
991d1eb903 feat: Programme + Legislaturen-Registry mit historisch korrekter Geltung
Neue Module:
- app/programme.py: zentrale Programm-Registry (alle Wahl- und Grundsatz-
  programme in einem Index), mit Geltungsdaten gueltig_ab/gueltig_bis und
  Helpern wahlprogramm_zum_zeitpunkt(), grundsatzprogramm_zum_zeitpunkt(),
  alle_versionen(). Skelett fuer 6 zusaetzliche Eintraege (CSU 2023,
  CDU NRW 2015, CDU SN 2023, CDU LSA 2023, SSW SH 2016, FREIE WAEHLER)
  vorbereitet — PDFs folgen.
- app/legislaturen.py: 56 Legislaturen + 70 Regierungen fuer 16 BL + Bund.
  Helper legislatur_zum_zeitpunkt(), regierung_zum_zeitpunkt(),
  regierungen_einer_wp() fuer historisch korrekte Antrags-Bewertung
  (z.B. Kemmerich-28-Tage-Kabinett, RP-Uebergang Dreyer III -> Schweitzer I,
  BUND Scholz-Ampel -> geschaeftsfuehrend -> Merz I).
- tests/test_legislaturen.py: 20 Tests zu Konsistenz + Historie.

Datenbasis: 8 BTW-2025-Wahlprogramme (CDU, CSU, SPD, GRUENE, FDP, AfD,
LINKE, BSW) als PDFs hinzugefuegt. SHA-256-Pinning in
app/wahlprogramm-shas.lock.json (separat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:18:33 +02:00
Dotty Dotter
7793705486 test: WAHLPROGRAMME→PROGRAMME-Match akzeptiert auch pdf-Field-Lookup
Mein BUND-Eintrag von vorhin nutzt 'cdu-grundsatzprogramm.pdf' als
file, aber embeddings.PROGRAMME hat den Schluessel 'cdu-grundsatz'
(historisch ohne 'programm'-Suffix). Der test_every_wahlprogramm_
has_embeddings_entry-Test ist deshalb rot geworden.

Test akzeptiert jetzt zwei Match-Pfade:
1. file-stem == PROGRAMME-Key (Standard fuer LT-Programme)
2. file == PROGRAMME[pid].pdf (Spezialfall Grundsatzprogramme)

Damit bleibt die Konsistenz-Pruefung sinnvoll, ohne dass ich die
Embedding-Programme-Keys umbennenen + reindizieren muss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:30:41 +02:00
Dotty Dotter
70d9790b4b feat(#177): Programm-Treue im BELEGE-Layout — pro Partei zwei aufklappbare Blöcke (WP+PP)
- _row_to_detail liefert zitate inline pro Wahlprogramm/Parteiprogramm-Block
- Template rendert <details>: Summary mit Score-Chip, Body mit Einschätzung+Belege
- v2.css: neue Klassen v2-treue-block/-label/-body, v2-pill, v2-einschaetzung
- Separate "Belege — Partei"-Sektion entfernt (ist jetzt inline pro Programm)

Tests: tests/test_v2_pdf_consistency.py (#176 generalisiert) bleibt grün —
fraktions_scores trägt zusätzliche zitate-Felder, ändert aber keine
Score/Begründungs-Werte aus dem Vergleich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:21:42 +02:00
Dotty Dotter
f6220b52e0 test(Phase 16): 9 E2E-Smoketests gegen gwoe-dev mit DEBUG_AUTH_TOKEN
Regression-Schutz fuer die heutigen UI-Fixes:
- test_cluster_list_has_cards (#56c68d3 — drucksachen vs members)
- test_cluster_detail_visible_after_click (#d72a6f3 — display:'')
- test_cluster_force_graph_renders (#60db39d — Edge-IDs)
- test_stimmverhalten_chart_has_size (#cb6971f — controls-bar-leak)
- test_score_histogram_has_buckets
- test_antrag_detail_has_markers (Heuchelei + Konsistenz + Mehrheits-Bar)
- test_antrag_matrix_coloring (#ee93fcd — rating-Shift)
- test_merkliste_add_and_delete (#c599e5f + #0394803 — onclick-quoting + ID-Lookup)
- test_aktuelle_themen_5_tabs

Aktivierung: DEBUG_AUTH_TOKEN gesetzt + pytest -m e2e.
Lokaler Run: 9/9 passed in 52s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:50:40 +02:00
Dotty Dotter
c3d4ab186f fix: icon()-Macro mit ignore-missing + Coverage-Test
Folge zum scales.svg-Vorfall (commit 01ea766):

1. icon.html: `{% include … ignore missing %}` — fehlende SVG-Files
   rendern jetzt leeren Span statt einen 500 auszuloesen. data-icon-
   Attribut zeigt den angefragten Namen, hilft im DevTools-Inspector.
2. tests/test_icons.py: scannt alle templates/-Files nach
   icon("name")-Aufrufen und prueft, dass jedes referenzierte Icon
   als SVG-File existiert. 4 Tests, alle gruen — verhindert dass
   solche Aufrufe in Zukunft unentdeckt durchrutschen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:35:59 +02:00
Dotty Dotter
db2fdda66b test(#191 Phase 10.4): 10 Tests fuer presse_generator Style-Switch
MockBewerter zeichnet system_prompt + user_prompt + model auf, damit
der Style-Switch isoliert testbar ist.

Coverage:
- TestStyleSwitch: 'pm'/'thread'/'invalid' nutzen den richtigen Prompt
- TestPersist: style-Wert wird korrekt in presse_drafts gespeichert
- TestIdempotenz: gleiche (ds, url, style) liefert Cache-Treffer; pm und
  thread fuer gleiches Paar liefern getrennte Drafts; force=True umgeht
  den Cache
- TestThreadAutoSplit: Auto-Splitter aktiviert sich bei zu langen
  Threads ohne \\n\\n-Trenner; bereits gesplittete Threads bleiben

10 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:15:21 +02:00
Dotty Dotter
ba1f104c8e feat(#178 Folge): Thread-Auto-Splitter + Quality-Audit-Skript
- _split_into_thread_posts() splittet zu lange Bodies an Satzgrenzen
  in mehrere Posts ≤ max_chars (Default 280). Greedy: möglichst viele
  Sätze pro Post. Hashtags am Ende bleiben erhalten.
- generate_draft(style='thread') ruft den Splitter auf, wenn das LLM
  weniger als 3 Posts oder Posts > 290 chars liefert.
- 7 Unit-Tests fuer den Splitter (test_thread_splitter.py).
- scripts/pm-quality-audit.sh: prueft alle PM-Drafts gegen Verbotsliste
  (GWÖ-Score, Matrix-Codes, Floskeln) + Wortzahl + Absatzzahl + Post-Laengen.
  Markdown-Report-Output. Audit von 23 Drafts: 4/23 ohne Auffaelligkeit;
  Hauptbefund: PMs haeufig zu kurz, Threads splittten ohne Auto-Splitter
  nicht zuverlaessig — Splitter behebt das.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:08:57 +02:00
Dotty Dotter
62636b5a78 quality: 9 Tests fuer auto_rate_runs + ruff F401 in main.py
Phase 8 (Code-Pflege):

- Neue Test-Datei tests/test_auto_rate_runs.py (9 Cases) deckt
  record_auto_rate_run, list_auto_rate_runs, auto_rate_today_total
  und das Schema ab.
- list_auto_rate_runs sortiert jetzt by id DESC (statt started_at DESC),
  weil started_at nur sekundengenau ist und Sub-Sekunden-Inserts
  unstabilen Output produzierten.
- ruff --select F401 --fix auf main.py: 7 ungenutzte Imports entfernt
  (MAX_SEARCH_QUERY_LEN, import_json_assessments, KLEINE_ANFRAGE,
  BUNDESLAENDER, lokale sqlite3/json/timezone-Reimports). Tests
  weiterhin grün (74 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:58:49 +02:00
Dotty Dotter
5823828fec feat: Opportunismus-Marker bei JA-Stimmen mit WP-Score < 3
Symmetrisch zur Heuchelei-Logik: bei JA-Fraktionen, deren eigener
Wahlprogramm-Score < 3 ist, erscheint ein dezenter italic '!' mit
Tooltip. 11 echte Cases gefunden auf dev (NRW + BB).

app/marker.py: opportunismus_score() — neun neue Tests (test_marker.py
jetzt 44 grün).

Refs: ADR 0010, Phase 2.4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:06 +02:00
Dotty Dotter
a8f85bf3ee test: 11 weitere Smoketests fuer Stimmverhalten-Endpoints
Smoketests fuer alle 7 Stimmverhalten-Aggregat-Endpoints (stimm-index,
heuchelei, empfehlungs-konsistenz, pro-wert, cross-bl, zeitreihe) plus
zwei CSV-Tests (Header-Spalten + konsistente Datenzeilen-Spaltenzahl).

Refs: ADR 0010

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:45:59 +02:00
Dotty Dotter
9498ca4b97 refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests
Logik aus dem Jinja-Template (Heuchelei-Marker, Konsistenz-Block,
decisive-Outcome-Selection) in app/marker.py extrahiert. Template
ruft die drei Helper als Jinja-Globals auf. Damit ist die Logik
testbar ohne Render-Kontext.

Plus: app/pm_render.py als Python-Spiegelbild des JS-Mini-Markdown-
Renderers in aktuelle-themen.html — fuer Tests und potenzielle
Server-side-Render-Optionen (z.B. PM-Mail).

Tests:
- tests/test_marker.py (35 Cases): heuchelei_score, decisive_outcome,
  consistency_state inkl. Multi-Vote, ambivalente Empfehlung,
  Edge-Cases.
- tests/test_pm_render.py (21 Cases): Bold, Italic, Listen,
  HTML-Escape, Paragraph-Splitting, snake_case-Schutz.

Refs: ADR 0010, ADR 0011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:44:12 +02:00
Dotty Dotter
b6b0ce752a feat(#170): sparsame **fett**-Hervorhebungen + Smoke-Tests fuer Histogram/Stand
PM-Prompt erlaubt nun max. eine Markdown-Bold-Markierung pro Absatz
(Schluessel-Zahl/Effekt). Force-Regen-Test bestaetigt: qwen-max liefert
**30 %** wie im Beispiel; renderPmBody im Frontend rendert das als
<strong>. Smoketests gegen die neuen Endpoints (score-histogram x4,
admin/stand x2 Auth-Walls) absichern Regressionen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:32:54 +02:00
Dotty Dotter
d30fcb132a feat: Stand-Dashboard, Score-Histogram, PM-Markdown, Live-Polling, Cluster-Indicator
Sechs zusammengehoerige UX/Performance-Erweiterungen:

**1. /v2/admin/stand — System-Stand-Dashboard**
KPI-Kacheln (Bewertungen, Plenum-Votes, Match, Vote-Orphans, News, PM-
Drafts, Bookmarks) + GWÖ-Score-Histogram + Per-BL-Tabelle + News-Source-
Tabelle. Auto-Refresh 30 s. Endpoint /api/admin/stand liefert alles in
einem Roundtrip. Nav-Eintrag "Stand" in der Admin-Sektion.

**2. /auswertungen Score-Histogram-Tab**
4. Tab "Score-Verteilung" mit Bar-Chart 0–10. Endpoint
/api/auswertungen/score-histogram liefert Buckets, optional gefiltert
nach Bundesland + Wahlperiode. Reagiert auf den globalen BL-Filter.

**3. PM-Body Markdown-Rendering**
Mini-Renderer im Modal: **bold** / __bold__ / *italic* / _italic_ /
- list-bullets / Doppel-Newline-Paragraphen. Kein externer Markdown-
Parser, keine neue Dependency. Body wird HTML-escaped, Patterns dann
zu Tags umgesetzt.

**4. Performance-Cache fuer themen_matching**
TTL-Cache (60 s) fuer aggregate_top_themen und aggregate_news_cluster.
Cache-Key inkl. aller Filter-Parameter. Automatische Invalidation in
news_aggregator.run_aggregator nach erfolgreichem Insert/Embed.
4 neue Tests fuer cache_get/set/clear-Verhalten.

**5. Stimmverhalten Banner Live-Update**
Statt setTimeout(800) jetzt pollQueueUntilDrained: alle 4 s
GET /api/queue/status, Banner zeigt pending + elapsed live. Bei
pending=0 zwei Polls in Folge: Banner + Stimmverhalten-Charts neu
laden. Max 5 Min Polling-Timeout. Bricht ab wenn Tab gewechselt wird.

**6. Antrag-Detail Cluster-Indicator**
News-Match-Box im Antrag-Detail laedt parallel /aktuelle-themen/cluster
und mappt URL → Cluster. Pro News-Card ein "🔗 Cluster (N News)"-Badge
mit Hover-Tooltip der anderen Cluster-Members. Macht thematische
Bündel sichtbar, ohne Pop-Out auf den Cluster-Tab.

Suite: 1088 → 1092 grün (4 Cache-Tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:49:06 +02:00
Dotty Dotter
aef8f83a08 feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen
**News-Match-Box im Antrag-Detail:**
Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine
Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der
letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button
mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben).

Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den
News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und
bleibt komplett versteckt wenn keine Matches existieren (kein Noise).

**Test-Coverage fuer die heutigen Backend-Aenderungen:**

`tests/test_llm_bewerter.py`:
- 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr,
  outside-string, makes-invalid-valid, preserves-already-escaped)
- 2 Tests fuer `json_object_mode` pass-through (off → kein Param,
  on → response_format={"type":"json_object"})
- 1 Integration: Recovery greift im bewerte()-Loop ohne Retry

`tests/test_endpoints_smoke.py`:
- Vote-Orphans-Endpoint (GET) Smoke
- Vote-Orphans-Auto-Rate Auth-Wall
- Batch-Analyze Auth-Wall (incl. ALL-Modus)
- Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster,
  drafts-list, drafts-versions) — 8 Tests

`tests/test_batch_helpers.py`:
- 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks
  (already-rated skip, no-adapter, limit-cap, empty-text-skip)

Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil
FastAPI nicht importbar, greifen aber gegen dev/CI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
Dotty Dotter
8136a1a10b feat(#172): Vote-Orphans-Banner + Bulk-Auto-Bewerten-Endpoint
Datenlage auf dev: 7281 Plenum-Votes, 96 Bewertungen, nur 19 Matches.
Stimmverhalten-Tab zeigt fast nichts, weil die meisten Vote-Drucksachen
keine Bewertung haben. Issue #172 schliesst die Luecke.

**Banner im Stimmverhalten-Tab:**
- Zeigt Anzahl + Verteilung pro BL der "Vote-only"-Drucksachen
- Nur sichtbar wenn count > 0
- Aktion: "Auto-Bewerten Top-N" mit Limit-Selector (5/10/20)

**Endpoint `GET /api/auswertungen/vote-orphans`:**
LEFT JOIN plenum_vote_results vs assessments, count + by_bundesland +
Top-N items sortiert nach parsed_at desc.

**Endpoint `POST /api/auswertungen/vote-orphans/auto-rate`:**
Admin-only, rate-limited 3/min. Nimmt Top-N Orphans, lädt Antragstext
per Adapter, enqueued einen Bewertungs-Job pro Drucksache. Defaults
limit=10, max 50. Per-skipped-reason-Liste in der Response (Adapter
fehlt, Empty-Text, Queue-full, etc.).

**Tests:** 4 neue (`TestGetVoteOrphans`), Suite 1071 gruen.

Helper `_enqueue_for_bl` aus dem Batch-Endpoint wird hier indirekt
wiederverwendet (gleiche Job-Queue-Pipeline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:03:23 +02:00
Dotty Dotter
e27dfc30a2 feat(#170 followup 2): Pre-Filter, Cluster, Antrags-Initiative, PM-Versionierung, Mail-Link
User-Feedback: Aktuelle-Themen-Dashboard war "Detective-Modus" — durch
viele News scrollen, Match-Stärke selbst interpretieren. Komplett-Refactor
zur kuratierten Sicht mit Tabs.

**1. Pre-Filter + GWÖ-Relevanz-Score (#134)**

`compute_relevance(matches)`: Score = max(antrag.gwoe_score × similarity).
Level: high (≥4.0) / mid (≥2.5) / low (>0) / none.
Pro News in der UI ein farbiger Pill (gruen/orange/grau) + Reason-Text:
"GWÖ-9.0/10-Antrag „Klimaschutzgesetz" (GRÜNE) passt mit Similarity 0.55."

Default-Filter "Nur GWÖ-relevant" aktiv (only_relevant=true) — zeigt
nur high/mid News, blendet Rauschen aus. Toggle-Checkbox.

`/api/aktuelle-themen/top` neuer Param `only_relevant=true|false`.

**2. PM-Versionierung im Modal (#135)**

`list_drafts_for(drucksache, news_url)`: alle Versionen, neueste oben.
Endpoint `/api/aktuelle-themen/drafts-versions`. Modal zeigt Dropdown
wenn >1 Version, Switch ohne LLM-Call. Force-Regen bleibt als Button
im "bestehender Entwurf"-Banner.

**3. News-Cluster-View (#136)**

`aggregate_news_cluster(intra_threshold=0.55, min_cluster_size=2)`:
Greedy-Embedding-Cluster + zentralster Antrags-Match per Centroid-
Vektor. Zweiter Tab "Themen-Cluster": 5 News über "Pflege" → 1 Cluster
mit gemeinsamem Antrag-Vorschlag, statt 5 separate Cards.
Endpoint: `/api/aktuelle-themen/cluster`.

**4. Mail-Direkt-Link + Clipboard (#137)**

Im PM-Modal zwei Buttons:
- "📧 Per Mail versenden" (mailto: mit subject + body, ~1900 Char Limit)
- "📋 In Zwischenablage kopieren" (navigator.clipboard.writeText)
- Bei langem PM (>1900 Char): mailto-Link wird ausgegraut, Hinweis
  "PM zu lang für Mail-Link — Clipboard nutzen"

**5. Antrags-Initiative (#138)**

`aggregate_top_antraege_with_news(min_gwoe_score=8.0, days=14)`:
Reverse-Sicht — pro Antrag mit GWÖ ≥ 8 die News-Resonanz. Antraege
ohne Match werden trotzdem angezeigt mit "keine News"-Pill.
Dritter Tab "GWÖ-Top-Anträge". Endpoint `.../top-antraege`.

**UI-Restrukturierung:** statt einer langen Scroll-Liste jetzt
5 Tabs mit gemeinsamer Filter-Bar:
- News × Anträge (Default, kuratiert via Pre-Filter)
- Themen-Cluster (Bündel ähnlicher News)
- GWÖ-Top-Anträge (Reverse)
- News-Volumen (Chart)
- PM-Entwürfe (Drafts-Liste)

Default min_similarity 0.40 → 0.50 erhoeht (weniger Rauschen).

Tests: 14 neue (compute_relevance × 5, only_relevant + sort × 3,
cluster × 3, top_antraege × 3). Suite 1067 gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:41:31 +02:00
Dotty Dotter
2bff943e8a feat(#170 followup): PM-Generator Idempotenz + qwen-max + Wrapper-Verbesserungen
User-Feedback nach Live-Test:

**1. Idempotenz** — Pressemitteilungen wurden ungespeichert generiert,
   doppelter Klick erzeugte doppelten Draft + LLM-Kosten.

   - Neuer Helper `_find_existing_draft(drucksache, news_url)` der den
     neuesten Draft fuer das Paar zurueckgibt
   - `generate_draft()` prueft per Default zuerst den Lookup, liefert
     existing zurueck mit `_was_existing=True` (kein LLM-Call)
   - `force=True` Parameter fuer bewusste Neu-Generierung
   - Endpoint nimmt `?force=true` Query-Param entgegen
   - UI: Modal zeigt klar "Bestehender Entwurf vs Neu generiert" Banner,
     mit "Neu generieren"-Button im existing-Banner

**2. Premium-Modell statt Default** — User wollte hoehere Sprachqualitaet
   ("Opus oder sowas"). Da das Projekt Qwen via DashScope nutzt (kein
   Anthropic), Wechsel auf `settings.llm_model_premium` (qwen-max).

   - Tradeoff: ~3× teurer (~6 Cent statt 2 Cent) und ~2× langsamer
     (~30 s statt 15 s) — aber spuerbare Qualitaetsverbesserung in
     Pressemitteilungs-Diktion
   - confirm-Dialog im Frontend nennt jetzt 6 Cent + 30 s

**3. Wrapper-Verbesserungen** — `auto-fetch-news.sh` aufgeraeumt:
   - Container-Check (skip wenn down) analog zu run-digest.sh
   - START/END-Timestamps
   - Ausfuehrliche cron-install-Doku im Header
   - Auto-Backfill: wenn erster Run >= 100 Embeddings (Limit gehit),
     wird embed_pending_articles bis zu 500 weitere nachgeholt

Tests: 5 neue (idempotency, force, _find_existing_draft × 3). Suite
1053 gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:10:20 +02:00
Dotty Dotter
d54ce23e42 feat(#170): Aktuelle-Themen-Dashboard — News × Anträge × Pressemitteilungen
Vollständiges 4-Phasen-Feature:

**Phase 1 — News-Aggregator** (`app/news_aggregator.py`)
- Tagesschau-API (`/api2u/news?ressort=...`) für inland/ausland/wirtschaft/wissen
- Bundestag-RSS für aktuellethemen / pressemitteilungen / hib
- DB-Tabelle `news_articles` (URL-PK, idempotent)
- Embeddings via existierender qwen-v4-Pipeline
- Cron-Script `scripts/auto-fetch-news.sh`
- Bewusst NICHT: RND.de (robots.txt bannt explizit ClaudeBot, GPTBot,
  CCBot, ChatGPT-User, Google-Extended). Nur AI-erlaubende, öffentlich-
  rechtliche/parlamentarische Quellen
- Volltexte werden NICHT persistiert (nur Titel + erster Satz)

**Phase 2 — Themen × Anträge Matching** (`app/themen_matching.py`)
- News-Embedding × Assessment-summary_embedding via Cosine-Similarity
- `find_anträge_for_news`: pro News die Top-K passenden Anträge
- `find_news_for_antrag`: pro Antrag Top-K News mit Datums-Fenster (90d)
- `aggregate_top_themen`: primärer Dashboard-Endpoint
- `aggregate_themen_zeitreihe`: News-Volumen pro Tag × Source

**Phase 3 — Dashboard-View** (`/aktuelle-themen`)
- Neuer linker Nav-Eintrag „Aktuelle Themen"
- Stacked-Area-Chart News-Volumen pro Quelle (30d)
- Pro News-Card: Titel + Summary + Tags + Top-3-Antrags-Match-Liste
  mit GWÖ-Score-Pill, Drucksache-Link, PM-Vorschlag-Button
- Filter: Zeitfenster, Top-N, min_similarity
- Auth-protected (require_auth)

**Phase 4 — Pressemitteilungs-Generator** (`app/presse_generator.py`)
- LLM-Prompt-Template (200-250 Worte, GWÖ-Sicht, JSON-Output)
- Reuse von `QwenBewerter` aus app/adapters/qwen_bewerter.py
- DB-Tabelle `presse_drafts` (Persistenz)
- POST `/api/aktuelle-themen/generate-presse` rate-limited 5/min,
  auth-only (LLM-Kosten)
- GET `/api/aktuelle-themen/drafts` + `/drafts/{id}` für Liste/Detail
- Manueller Trigger via UI-Button, kein Auto-Versand
- Modal-Anzeige des generierten Texts

**Compliance:**
- robots.txt-respektierend (ClaudeBot-Bann von RND vermieden, AI-
  erlaubende Quellen verwendet)
- UI zeigt nur Titel+URL+Datum+erster Satz, keine Volltext-Reproduktion
- Pressemitteilungen sind explizit Drafts, nicht Auto-Versand
- LLM-Calls rate-limited, auth-only

**Tests:** 43 neue Tests (19 news_aggregator + 16 themen_matching +
8 presse_generator). Suite jetzt 1048 grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:39:36 +02:00
Dotty Dotter
1e381d23ab feat(#168): Über-Zeit-Drift im Stimmverhalten-Tab
Stimm-Index pro Fraktion über Quartale. Linien-Chart pro Fraktion,
Lücken bei Quartalen mit n<3 (Ja UND Nein). Macht sichtbar, ob sich die
Gemeinwohl-Affinität einer Fraktion innerhalb der Wahlperiode verschiebt.

- `_quarter_for(datum)` Helper: ISO-Datum → "YYYY-Qn".
- `aggregate_stimm_index_zeitreihe()` analog zu pro_wert/pro_gruppe,
  aber nach Quartal-Bucket statt Achse.
- `GET /api/auswertungen/stimm-index-zeitreihe?parteien=CDU,SPD,...`
- 4. Sub-Section im Stimmverhalten-Tab: Multi-Linien-Chart mit
  Partei-Farben (CDU schwarz, SPD rot, GRÜNE grün, FDP gelb, AfD blau,
  LINKE pink, BSW lila, SSW navy, BVB-FW orange).

Bei aktueller Sparse-Datenmenge (35 Assessments × 4 Quartale) ist der
Chart heute meist leer — Infrastruktur ist ready, fuellt sich automatisch
mit Issue #44 Batch-Bewertung.

Tests: 10 neue (4 _quarter_for, 6 aggregate). Suite jetzt 1005 grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:03:53 +02:00
Dotty Dotter
79003d6056 feat(#166): Berührungsgruppen-Aufschlüsselung im Stimmverhalten-Tab
Stimm-Index pro Beruehrungsgruppe (Matrix-Zeilen A-E) zusaetzlich zur
bestehenden Werte-Aufschluesselung (Spalten 1-5). Toggle-Buttons in der
3. Sub-Section schalten zwischen Werte/Gruppen.

- `aggregate_stimm_index_pro_gruppe()` analog zu `_pro_wert`, aber
  gruppiert nach `field[0]` (A-E) statt `field[-1]` (1-5).
- `_gruppen_score_for_assessment()` Helper.
- `GET /api/auswertungen/stimm-index-pro-gruppe`.
- UI-Toggle "Pro GWÖ-Wert" / "Pro Berührungsgruppe" mit `setMatrixAxis()`.
- 6 neue Tests, Suite jetzt 995 grün.

Beruehrungsgruppen-Labels (aus app/models.py:MATRIX_LABELS gekuerzt):
- A: Ausgelagerte Betriebe / Lieferant:innen
- B: Finanzpartner:innen / Steuerzahler:innen
- C: Politische Führung / Verwaltung / Ehrenamt
- D: Bürger:innen und Wirtschaft
- E: Staat, Gesellschaft und Natur

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:00:35 +02:00
Dotty Dotter
d81753c4fb feat(#167): Empfehlungs-Konsistenz + CSV-Export Stimmverhalten
Phase-2-Erweiterungen des Stimmverhalten-Tabs:

**1. Empfehlungs-Konsistenz (#167):**
Pro Fraktion: Anteil der Anträge mit GWÖ-Empfehlung
"Uneingeschränkt unterstützen" oder "Unterstützen mit Änderungen",
bei denen die Fraktion trotzdem NEIN gestimmt hat. Orthogonal zur
Heuchelei-Quote — prüft NICHT gegen Wahlprogramm-Treue, sondern gegen
die GWÖ-Empfehlung des Systems.

- `aggregate_empfehlungs_konsistenz()` in app/auswertungen.py
- `GET /api/auswertungen/empfehlungs-konsistenz`
- 5. Chart-Sub-Section im Stimmverhalten-Tab (rote Bar Chart, 0..100%)

**2. CSV-Export (Phase-1-Querschnitts-TODO):**
Long-Format-CSV mit Spalten: drucksache, bundesland, wahlperiode, datum,
gwoe_score, empfehlung, partei, vote, ist_antragsteller. Macht alle
Stimmverhalten-Aussagen wissenschaftlich auswertbar (R/pandas/Excel).

- `export_stimmverhalten_csv()` in app/auswertungen.py
- `GET /api/auswertungen/stimmverhalten.csv` mit
  Filter-Parametern bundesland/wahlperiode/exclude_antragsteller
- "CSV-Export"-Button im Stimmverhalten-Tab neben dem Toggle

**Tests:** 27 Stimmverhalten-Tests (war 18, +4 Empfehlungs-Konsistenz,
+5 CSV-Export). Fixture um `empfehlung`-Spalte erweitert.

Suite: 989 Tests grün (war 980).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:56:35 +02:00
Dotty Dotter
5eabe0d9b3 feat: Stimmverhalten × Gemeinwohl-Orientierung in /auswertungen
Neue Auswertungs-Sicht: Welche Fraktionen stimmen häufiger gemeinwohl-
orientierten Anträgen zu? Verschneidet GWÖ-Bewertung pro Antrag mit
dem tatsächlichen Plenum-Stimmverhalten der Fraktionen.

Vier Aussagen, alle hinter dem neuen Tab "Stimmverhalten":

1. **Gemeinwohl-Stimm-Index** pro Fraktion: Ø-GWÖ-Score der JA-Anträge
   minus Ø-GWÖ-Score der NEIN-Anträge. Domain −10..+10. Positiv = stimmt
   eher Gemeinwohl-affinen Anträgen zu.

2. **Heuchelei-Quote** pro Fraktion: Anteil der Anträge mit
   wahlprogramm_score ≥ 7 (passt zum eigenen Wahlprogramm), bei denen
   die Fraktion trotzdem NEIN gestimmt hat.

3. **Stimm-Index pro GWÖ-Wert** als Heatmap: 5 Spalten (Würde,
   Solidarität, Nachhaltigkeit, Gerechtigkeit, Demokratie) aus den
   gwoe_matrix-Suffix-Spalten. Domain −5..+5 pro Zelle.

4. **Cross-BL-Vergleich** als Grouped Bar: gleiche Fraktion in
   mehreren Ländern. Nur Fraktionen in ≥2 BL mit ausreichender
   Datenbasis.

Querschnitt:
- `exclude_antragsteller=True` per Default (Toggle-Checkbox in UI),
  weil Antragsteller-Fraktionen quasi immer JA stimmen → würde Index
  verzerren. Toggle macht den Effekt sichtbar.
- `min_n=5` pro Fraktion fuer Stimm-Index, n=3 fuer Heatmaps.
  Fraktionen unter dem Cutoff werden als "Nicht aussagekräftig" separat
  gelistet.
- Caveat-Banner mit `n_assessments_matched` über jedem Chart.

Implementation:
- `app/auswertungen.py`: `_load_assessments_with_votes()` JOIN-Helper
  + 4 Aggregat-Funktionen analog zu `aggregate_matrix`-Pattern.
  Reuse: `normalize_partei` für Aliasing (BÜNDNIS 90/DIE GRÜNEN →
  GRÜNE), `wahlperiode_for` für WP-Filter.
- `app/main.py`: 4 neue read-only GET-Endpoints unter
  `/api/auswertungen/stimm-index|heuchelei|stimm-index-pro-wert|
  stimm-index-cross-bl`.
- `app/templates/v2/screens/auswertungen.html`: 4. Tab "Stimmverhalten"
  mit 4 Sub-Sektionen, Chart.js Bars + HTML-Heatmap-Tabelle.
- `tests/test_auswertungen_stimmverhalten.py`: 18 neue Tests
  (Fixture-DB mit 13 Assessments + 13 Vote-Results, Edge-Cases:
  GRÜNE-positiver-Index, AfD-negativer-Index, exclude_antragsteller-
  Effekt, min_n-Cutoff, leere DB).

Sparse-Data-Realität: aktuell 35 Assessments im prod, dünne Datenbasis
fuer einige Fraktionen. Feature wächst mit Issue #44 Batch-Bewertung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:30:02 +02:00
Dotty Dotter
33bb564ed1 feat(#149): BB-Parser produktiv — Brandenburger Plenarprotokolle (Status-Only)
URL-Pattern verifiziert WP8 Sitzung 22:
https://www.parlamentsdokumentation.brandenburg.de/starweb/LBB/ELVIS/parladoku/w8/plpr/{n}.pdf

**Wichtig:** parladoku-PDF-URL liefert 403 ohne Cookie-Session. Erst
GET auf portal/browse.tt.html?wp=8 zur Cookie-Akquise, dann mit
gesetztem Cookie die PDF-URL aufrufen. Ingest-Cron implementiert
diesen Flow per http.cookiejar.CookieJar in Python.

Anchor-Pattern (NRW-aehnlich):
- "Damit ist [Subj] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|ueberwiesen)"
- Drucksachen-Lookup: Drucksache 8/N rueckwaerts vom Anchor

Vote-Style: Handzeichen-only (kein Fraktionen-Listing). Daher
Vote-Listen leer; einstimmig=True setzt JA=alle WP8-Fraktionen
(SPD, AfD, CDU, BSW, GRÜNE).

Tests: 14 BB-Tests, Verifikation S22 → 26 Vote-Anchors extrahiert.
Stand: 10 produktive Parser
(NRW, BUND, BE, HH, TH, HE, SH, HB, SL, BB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:04:21 +02:00
Dotty Dotter
d0f7b9217c feat(#161): SL-Parser produktiv — Saarland HTML-Abstimmungsergebnisse
Saarland publiziert keine Wortprotokolle, sondern eigene HTML-Seiten
mit strukturierten Abstimmungsergebnissen pro Sitzung:

  <p>Drucksache 17/2076 ... in Erster Lesung mit Stimmenmehrheit
  angenommen ... [SPD: dafür; CDU und AfD: dagegen]</p>

Daher Input ist HTML, nicht PDF. Parser nutzt LI-Block-Iteration und
extrahiert pro Block:
- Drucksache aus "Drucksache N/M"
- Status aus "(einstimmig|mit Stimmenmehrheit)? (angenommen|abgelehnt)"
- Vote-Block aus "[SPD: dafür; CDU: dagegen; AfD: Enthaltung]"
- einstimmig=True falls Status enthaelt "einstimmig"

Vote-Bracket-Parser (eigenstaendig vs. Reden-Stil-Parser anderer BL):
- Splits per ; → "Phrase: Status"
- Phrase per Wortgrenzen-Regex auf {SPD,CDU,AfD} matchen
- Status-Map: dafür→ja, dagegen→nein, Enthaltung→enthaltung

URL-Pattern (nicht direkt vorhersagbar wegen Datums-Slug):
https://www.landtag-saar.de/aktuelles/mitteilungen/abstimmungsergebnisse-der-{n}-landtagssitzung-vom-{datum}/

Auto-Ingest via Index-Scrape (analog HH/HE/SH):
- /aktuelles/mitteilungen/ scrape
- WP16-URLs (mit "wahlperiode-vom") ueberspringen
- Pro neue Sitzung: HTML herunterladen, ingest_pdf-API auf .html-Datei

Tests: 18 SL-Tests (Verifikation Sitzung 46 → 18 Votes mit korrekten
JA/NEIN/ENTH-Listen). Stand: 9 produktive Parser
(NRW, BUND, BE, HH, TH, HE, SH, HB, SL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:53:51 +02:00
Dotty Dotter
d9ae0b0db8 feat(#153): HB-Parser produktiv — Bremer Beschlussprotokolle (Status-Only)
Bremen publiziert wie Hessen nur Beschlussprotokolle (TOPs + Status-Saetze),
KEINE Wortprotokolle mit Vote-Block. Daher minimaler Parser:
- Drucksache + Status (angenommen/abgelehnt/ueberwiesen)
- Vote-Listen bleiben leer (HB hat keine Fraktions-Detail)

Anchor-Regex: "Die Buergerschaft (Landtag|Stadtbuergerschaft) <verb> <rest> <terminator>"
Verb-Mapping:
- "lehnt ... ab" → abgelehnt
- "stimmt ... zu" → angenommen
- "beschliesst ..." → angenommen
- "verabschiedet ..." → angenommen
- "verweist|ueberweist|leitet" → ueberwiesen
- "nimmt ... Kenntnis" → uebersprungen (kein Vote)

Drucksachen-Aufloesung: erst Inline-Form "(21/N)", dann Block-Form
"Drucksache 21/N" rueckwaerts vom Anchor.

URL-Pattern (verifiziert WP21 Sitzung 33 Land):
https://www.bremische-buergerschaft.de/dokumente/wp21/land/protokoll/b21l{n4}.pdf

Cron unterstuetzt jetzt {n4}-Platzhalter (4-stellig). HB Land WP21
ingestiert via direktes URL-Probing (b21l0001.pdf … b21l9999.pdf).
Stadtbuergerschaft (b21s*) als Folge-Issue.

Tests: 21 HB-Tests, Verifikation S33 → 20 Beschluesse extrahiert.
Stand: 8 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH, HB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:41:40 +02:00
Dotty Dotter
7ebdc78331 feat(#160): SH-Parser produktiv — Schleswig-Holsteiner Plenarprotokolle
Verifiziert auf WP20 Sitzungen 115 + 116. Format ist TH-aehnlich:

Result-Anchor: "Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|überwiesen|so beschlossen)"
Vote-Block (Q+A im Reden-Stil):
  - JA: "Wer dem zustimmen will ... Das sind die Fraktionen von X"
  - NEIN: "Wer stimmt dagegen? ... Das sind die Fraktionen von Y"
  - ENTH: "Wer enthaelt sich? ... Z"
Drucksachen-Lookup: rueckwaerts vom Anchor

Besonderheiten:
- SSW (5%-Huerden-befreit) als feste Fraktion
- "Damit ist die Ausschussueberweisung einstimmig so beschlossen" → ergebnis="ueberwiesen"
- "Das sind alle anderen Fraktionen" → NEIN als Komplement von JA inferiert
- Soft-Hyphen-Reparatur (PDF-Zeilenumbruch "zustim- men" → "zustimmen")
- _last_match-Helper, weil 1500-char-Window mehrere Vote-Bloecke enthalten kann
  (TH-Limitierung gefixed)

URL-Pattern (verifiziert):
https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf

Datum-Anteile (YYYY-Pfad + MM-YY-Suffix) machen URL-Vorhersage unmoeglich
→ Auto-Ingest-Cron via Index-Scrape (analog HH/HE):
https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/

Tests: 23 SH-Tests + Stub-Registry-Test angepasst.
Stand: 7 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:29:06 +02:00
Dotty Dotter
8125dbb731 feat(#154): HE-Parser produktiv — Hessen Beschlussprotokoll (Status-Only)
Hessen publiziert nur Beschlussprotokolle (Tagesordnung + Status), KEINE
Wortprotokolle mit Vote-Block. Daher minimaler Parser:
- Drucksache + Status (angenommen/abgelehnt/ueberwiesen)
- Vote-Listen bleiben leer (HE hat keine Fraktions-Detail)

URL-Pattern (verifiziert WP21 Sitzungen 61-63):
http://starweb.hessen.de/cache/hessen/landtag/Plenum/{wp}/Beschlussprotokoll_PL_{n}_{datum}.pdf

Datum-Teil DD-MM-YYYY → URL-Vorhersage unmoeglich, Auto-Ingest braucht
Index-Scrape via starweb.hessen.de/starweb/LIS/Pd_Eingang.htm (analog HH).

Status-Mapping:
- "angenommen" → ergebnis="angenommen"
- "Abgelehnt" → ergebnis="abgelehnt"
- "Nach (Aussprache|Lesung) an [Ausschuss]" → ergebnis="ueberwiesen"
- "Entgegengenommen", "Abgehalten", "Zur Kenntnis genommen" → uebersprungen

Tests: PROTOKOLL_PARSERS-Set jetzt {NRW, BUND, BE, HH, TH, HE}. STUB_BL_CODES
auf 11 BL reduziert (BB, BW, BY, HB, LSA, MV, NI, RP, SH, SL, SN bleiben).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:19:02 +02:00
Dotty Dotter
399dbc2639 feat(#163): TH-Parser produktiv — Thueringer Plenarprotokolle
Fuenfter produktiver Parser nach NRW + BUND + BE + HH.

URL-Pattern verifiziert (WP8 Sitzungen 1, 10, 20, 30, 40, 42):
  https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf

Anchor-Sprache (BE-aehnlich):
  Wer dem zustimmt, ... Das sind die Stimmen aus den Fraktionen der
  CDU, BSW, SPD und Die Linke. Wer stimmt gegen ...? Das sind die
  Stimmen aus der Fraktion der AfD. Damit ist [...] mehrheitlich
  angenommen.

Pattern:
- Result-Anchor: Damit ist [Subjekt] (mehrheitlich|einstimmig)?
  (angenommen|abgelehnt)
- Vote-Block: Wer dem zustimmt / Wer stimmt gegen / Wer enthaelt sich
- Drucksachen-Lookup: 'Drucksache 8/N' rueckwaerts

Fraktions-Mapping WP8 (ab Mai 2024): CDU, AfD, BSW, Linke, SPD
(WP7-Faktionen GRUENE/FDP fuer Backfill ebenfalls im Mapping).

Cron-PROTO_TARGETS um TH-WP8 erweitert. Stub-Test angepasst.
2026-04-29 01:11:58 +02:00
Dotty Dotter
edbce27c49 test(#155): 19 Tests fuer HH-Parser
- TestNormalizeFraktionenHh: SPD, GRUENEN-Form, Linken-Form, kombinierte
  Phrasen, Doppelzaehl-Schutz
- TestParseVoteBlockHh: voller Vote-Block, ja+nein ohne enth, leerer Block,
  nur ja
- TestResolveDrucksacheHh: 'Drucksache 23/N', bare '23/N', closest-match,
  None bei keinem Treffer
- TestResultAnchorRegex: einstimmig vs. mehrheitlich, angenommen/abgelehnt
- Konstanten-Sanity: alle 5 HH-Fraktionen im Mapping abgedeckt

919 Tests gruen (+19).
2026-04-29 01:05:33 +02:00
Dotty Dotter
5f97ae9fc3 feat(#155): HH-Parser produktiv — Hamburg Beschlussprotokolle
Vierter produktiver Plenarprotokoll-Parser nach NRW + BUND + BE.

Hamburg publiziert kompakte Beschlussprotokolle (Tabellen-Form mit
Vote-Block pro Beschluss):
  ... mehrheitlich mit den Stimmen der SPD und GRUENEN gegen die
  Stimmen der CDU und AfD bei Enthaltung der Linken angenommen

Pattern:
- einstimmig (angenommen|abgelehnt) — alle Fraktionen
- mehrheitlich mit den Stimmen X gegen die Stimmen Y bei Enthaltung Z
  (angenommen|abgelehnt)

Fraktions-Mapping WP23: SPD, GRUENE, CDU, AfD, Linke

URL-Discovery laeuft ueber die Protokoll-Liste der Buergerschaft
(Blob-IDs via Index-Page-Scrape). Cron-Eintrag erst sobald
URL-Discovery-Skript hier integriert ist.

Stub-Test angepasst (HH raus aus STUB_BL_CODES).
2026-04-29 00:57:58 +02:00
Dotty Dotter
c7d6ac7f5f feat(#150): BE-Parser produktiv — Berliner Abgeordnetenhaus-Plenarprotokolle
Dritter vollwertiger Plenarprotokoll-Parser nach NRW + BUND.

URL-Pattern verifiziert (WP19 Sitzungen 1, 10, 50, 80, 100):
  https://www.parlament-berlin.de/ados/{wp}/IIIPlen/protokoll/plen{wp}-{n:03}-pp.pdf

Anchor-Sprache (NRW-aehnlich, mit Berliner-Eigenheit 'pro forma'):
  Wer den Antrag auf Drucksache 19/X annehmen moechte, ... – Das sind
    die Fraktionen Buendnis 90/Die Gruenen und Die Linke.
  Wer stimmt dagegen? – Das sind die Fraktionen der CDU, SPD und AfD.
  Wer enthaelt sich, pro forma? – Das ist niemand.
  Damit ist der Antrag abgelehnt.

Pattern:
- Result-Anchor: Damit ist [Antrag/Aenderungsantrag/Gesetzentwurf/...]
  (angenommen|abgelehnt)
- Vote-Block: 3 Q+A-Paare im Reden-Stil (annehmen moechte / dagegen /
  enthaelt sich)
- Drucksachen-Lookup: 'Drucksache 19/N(-suffix)' rueckwaerts (1500-char Fenster)

Fraktions-Mapping WP19:
- Buendnis 90/Die Gruenen → GRÜNE
- Die Linke → LINKE
- CDU, SPD, AfD, FDP

21 Tests in test_protokoll_parsers_be.py.
Cron-PROTO_TARGETS erweitert um BE WP19 (~80 Sitzungen).
Stub-Test angepasst.

905 Tests gruen (889 → 905, +16 fuer BE).
2026-04-29 00:37:47 +02:00
Dotty Dotter
22a2b63c35 feat(#148): BUND-Parser produktiv — Bundestags-XML-Plenarprotokolle
Erster vollwertiger Plenarprotokoll-Parser nach NRW. Quelle:
https://dserver.bundestag.de/btp/{wp}/{wp}{n:03}.xml

Anchor-Sprache (verifiziert WP20 Sitzungen 30 + 100):
  'Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen
   gegen die Stimmen der CDU/CSU-Fraktion bei Enthaltung der AfD-Fraktion
   angenommen.'

Pattern:
- Subjekt: Beschlussempfehlung | Ueberweisungsvorschlag | Antrag | Gesetzentwurf
- Vote-Block: 'mit den Stimmen X / gegen die Stimmen Y / bei Enthaltung Z'
- Ergebnis: 'angenommen' oder 'abgelehnt'
- Drucksache rueckwaerts vom Anchor (1500 chars Window)
- Kind 'ueberweisung' invertiert ergebnis zu 'ueberwiesen'

Fraktions-Mapping (WP20 = Ampel):
- 'Koalitionsfraktionen' → SPD + GRÜNE + FDP
- 'Oppositionsfraktionen' → CDU/CSU + AfD + LINKE
- 'CDU/CSU-Fraktion', 'Fraktion Bündnis 90/Die Grünen', etc.

WP21 (ab 2025) braucht eigenes Mapping-Update.

26 Tests in test_protokoll_parsers_bund.py (Vote-Block-Parsing, Anchor-
Erkennung, Drucksachen-Lookup, End-to-End mit Mock-XML).

Cron + Ingest-CLI:
- PROTO_TARGETS-Format erweitert um PROTOKOLL_ID_PREFIX und {n3}-
  Placeholder fuer 3-stellig zero-gepaddetes BT-Schema (BTP20-N)
- ingest_votes URL-Suffix dynamisch (PDF vs XML) statt hardcoded .pdf
- Eintrag in PROTOKOLL_PARSERS (NRW + BUND)
- Stub-Test angepasst: BUND raus aus STUB_BL_CODES

889 Tests gruen (787 → 889, +102 fuer Phase-2 Stubs+Tests+BUND).
2026-04-28 23:21:39 +02:00
Dotty Dotter
62fd25fbcb test(#106 Folge): Safety-Net fuer 16 Stub-Parser
81 Tests pruefen pro Stub:
- Modul ist importierbar
- Docstring enthaelt Recherche-Findings + Issue-Link
- parse_protocol() raised NotImplementedError mit informativer Message
- Stub ist NICHT in PROTOKOLL_PARSERS-Registry (sonst wuerde Cron crashen)
- Wenn parse_protocol kein NotImplementedError mehr wirft (also echt
  implementiert), MUSS es in PROTOKOLL_PARSERS sein — sonst Test rot

Damit ist sichergestellt: sobald ein Stub durch echten Parser ersetzt
wird, kann der Implementer nicht vergessen, gleichzeitig den Eintrag
in der Registry zu setzen.

868 Tests gruen, 787 → 868 (+81).
2026-04-28 23:11:38 +02:00
Dotty Dotter
16ecd31e50 test(#134): report.py Coverage 44.3% → 52.7%
- TestGetScoreColor: alle 5 Branches (>=7 blue, >=4 green, >=2 yellow,
  >=1 orange, sonst red)
- TestGetRatingSymbol: alle 5 Symbole (++, +, ○, −, −−)

Verbleibend (Lines 487-641): WeasyPrint-PDF-Render-Pfade — brauchen
echtes WeasyPrint-Setup, gehoeren in tests/integration/.

Total: 53.2% → 53.4%, 777 → 787 Tests.
2026-04-28 11:13:20 +02:00
Dotty Dotter
ccff2e3e8e test(#134): NRW Protokoll-Parser Coverage 51.7% → 85.1%
parse_protocol mit fitz-Mock (FakeDoc/FakePage):
- simple_angenommen mit ja/nein-Block
- einstimmig direct_broad → ja-Liste fallback
- ueber + so beschlossen → einstimmig-Fallback fuellt ja-Liste mit
  ALLE_FRAKTIONEN_NRW
- skips_anchor_without_drucksache: kein vorheriges 'Drucksache' → skip

compare_to_fixture:
- perfect_match → 1/1
- not_found → 0/1 mit 'NOT FOUND'-Error
- nicht_gesondert_abgestimmt: korrekt nicht-gefunden zaehlt als match
- wrong_ergebnis → error 'ergebnis X != Y'

Total Coverage: 52.1% → 53.2%, 769 → 777 Tests.
2026-04-28 11:11:52 +02:00
Dotty Dotter
58bfc84c41 test(#134): auth.py Coverage 47.1% → 86%
Security-kritisch — jetzt mit umfassender Test-Abdeckung:

- TestKeycloakUrls: issuer + jwks-URL-Konstruktion
- TestGetJwks: Cache-Hit (frisch), Fetch bei leerem Cache, Stale-Cache
  bei HTTP-Fehler (statt komplettem Crash)
- TestValidateToken: kein JWKS → None
- TestGetCurrentUser: Auth-disabled → None, kein Token → None
- TestRequireAuth: Dev-Modus, 401 ohne Token, 401 ungueltig, 200 mit
  validem Token
- TestRequireAdmin: Dev-admin, admin-Rolle, gwoe-admin-Rolle, 403 ohne
  Admin-Rolle
- TestKeycloakAdminToken: keine Credentials → 500, Erfolg → access_token,
  Keycloak-Fehler → 500

Verbleibend: kid-not-found-Pfad, ExpiredSignature/JWTError/ImportError-
Branches im _validate_token-Inneren — wuerden voll gemockten jose-Stack
brauchen.

Total Coverage: 51.2% → 52.1%, 750 → 769 Tests.
2026-04-28 11:10:08 +02:00
Dotty Dotter
3edb1e7501 test(#134): queue Coverage 26.6% → 43.4%
- TestStartWorker: erzeugt CONCURRENCY Tasks, ersetzt aktive nicht
- TestGracefulShutdown:
  - leerer Status → sofortiger Return
  - 'processing'-Job laesst shutdown warten bis er fertig ist
  - Timeout loggt ERROR
- TestEnqueueShuttingDown: enqueue blockiert mit QueueFullError waehrend
  Shutdown

Verbleibend: _worker-Hauptloop (while True, hart zu testen) und
re_enqueue_pending (DB+Adapter-I/O, eigenes Setup noetig).

Total Coverage: 50.8% → 51.2%, 744 → 750 Tests.
2026-04-28 11:08:04 +02:00
Dotty Dotter
8e6f435b94 test(#134): analyzer Coverage 70.1% → 83.1%
- TestContentFingerprint: empty/non-empty cases (Lines 45-48)
- TestGetDefaultBewerter: lazy-Import liefert QwenBewerter (Lines 58-60)
- TestLoadContextFile: existierende + fehlende Datei (Line 71)
- TestGetUserPromptTemplate: alle 4 Platzhalter im Template
- TestGetBundeslandContext:
  - unbekanntes BL → ValueError 'Unbekanntes Bundesland' (Line 263)
  - inaktives BL → ValueError 'nicht aktiv' (Line 265)

Verbleibend (alles im analyze_text LLM-Pfad): Embeddings-Fallback,
reconstruct_zitate-Branch, missing-Programme-Logging — wuerde End-to-End
Mock-Setup brauchen, Aufwand vs. Nutzen unguenstig.

Total: 50.6% → 50.8%, 736 → 744 Tests.
2026-04-28 11:06:24 +02:00
Dotty Dotter
98f7e610b4 test(#134): drucksache_typen Coverage 72.5% → 100%
likely_kleine_anfrage_titel-Heuristik (#149-Folge):
- empty/None Titel false
- 'Welche', 'Warum', 'Was' und andere Frage-Praefixe true
- Frage am Ende mit '?' true
- Nummern-Praefix (NRW '1Welche...', '12. Wie viele...') wird weg-gestrippt
- pure Digits-only Titel: nach Strippen leer → false
- case-insensitive Praefix-Match
- normaler Antrag-Titel ohne Frage → false

Coverage 50.4% → 50.6%, 724 → 736 Tests.
2026-04-28 11:04:31 +02:00
Dotty Dotter
581d1591b8 test(#134): clustering.py Coverage 82.3% → 99.3%
- TestUnionFindRankSwap: rank-Asymmetrie-Branch (Line 69)
- TestLoadAssessmentItems: tmp-DB mit korrekten + kaputten Embeddings,
  bundesland-Filter, vollstaendiges Item-Schema
- TestBuildHierarchySubclusters:
  - max_cluster_size=3 zwingt grossen Cluster zu sub-clustern
  - kleiner Cluster bekommt subclusters=None

Total Coverage: 49.9% → 50.4% (50%-Marke ueberschritten),
718 → 724 Tests.
2026-04-28 11:02:58 +02:00
Dotty Dotter
999926b5f3 test(#134): monitoring.py Coverage 83.2% → 99.3%
- TestSearchAdapterFallbackLogging: erster Query-Versuch failt mit
  Debug-Log, dritter klappt
- TestDailyScanDbUpsertFailure: erster upsert_monitoring_scan crasht,
  zweiter klappt → der Rest des Protokolls wird nicht blockiert,
  ERROR-Log ist da
- TestSendMonitoringDigest:
  - mail_sent=True bei erfolgreichem send_mail
  - mail_sent=False bei SMTP-Fehler, aber kein Crash

Verbleibend: Line 122 (return [] nach drei Fallback-Misses ohne
Exception — schwer ohne Adapter-Mock zu provozieren).

Total Coverage: 49.5% → 49.9%, 714 → 718 Tests.
2026-04-28 11:01:19 +02:00
Dotty Dotter
e69ca1c29d test(#134): mail.py Coverage 88.2% → 100%
- TestSendSync.test_raises_when_smtp_not_configured: leerer host/user
  fuehrt zu RuntimeError
- TestSendSync.test_calls_smtp_ssl_with_settings: smtplib.SMTP_SSL wird
  mit host/port instanziiert, login + send_message aufgerufen
- TestSendMailAsync.test_runs_send_sync_in_executor: send_mail()
  delegiert per loop.run_in_executor an _send_sync
2026-04-28 10:58:03 +02:00