Commit Graph

63 Commits

Author SHA1 Message Date
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
Dotty Dotter
9af74b1a05 test(#134): qwen_bewerter Coverage 86% → 94%
- TestContentFingerprint: leerer/None content → 'len=0', sha1-Praefix
- TestStripMarkdownJsonFences: explizite ```json-Sprache-Erkennung
- TestLazyClientInstantiation:
  - injected client umgeht Lazy-Import
  - kein injected client triggert openai.AsyncOpenAI-Aufruf
    (sys.modules-Stub fuer Lazy-Import-Branch)

Verbleibend uncovered: Line 46 (json-Fence ohne Newline, defensiv aber
unerreichbar weil split('\n', 1) vorher crashen wuerde) und 110-111
(assert/raise-Pfad, im Code als 'unreachable' markiert).
2026-04-28 10:56:56 +02:00
Dotty Dotter
698562b1f5 test(#134): Coverage-Backfill auswertungen + Repositories
- app/auswertungen.py 87.4% → 97.9%
  - TestLoadAssessmentsRobustness: ungueltiges JSON in fraktionen-Spalte
    fallback to []
  - TestAggregateMatrixSkipsBlanks: bundesland-NULL-Eintrag wird ignoriert
  - TestGetWahlperioden: sortierte Liste

- app/repositories/abonnement_repository.py 85.2% → 100%
- app/repositories/antrag_repository.py 87.0% → 98.1%
- app/repositories/bewertung_repository.py 90% → 100%

Pattern fuer Sqlite-Repos: AsyncMock auf database.X-Funktion, dann
pruefen dass die Methode korrekt delegiert (Argumente, Return-Wert).
Trivial wrappers, aber jetzt auditierbar.

Total: 48.7% → 49.2%, 686 → 705 Tests.
2026-04-28 10:54:28 +02:00
Dotty Dotter
b13b46a444 test(#134): Coverage-Backfill drei Module
- app/ingest_votes.py 39.2% → 100%
  - TestDownloadPdf: schreibt Bytes, propagiert HTTP-Fehler
  - TestCli: --supported, kein-arg-error, fehlender PDF-Pfad,
    pdf-Pfad-Run, --url-Download-Pfad, exit-Code 2 bei null Resultaten,
    Errors-Liste im Output
  - DB-Error-Collection in ingest_pdf

- app/wahlprogramme.py 90.7% → 100%
  - TestLoadWahlprogrammText: paged-Datei, Normal-Datei-Fallback,
    fehlende Datei
  - TestSearchWahlprogramm: leere Returns
  - TestFindRelevantQuotes: ValueError bei unbekanntem BL
  - TestFormatQuoteForPrompt: leeres Dict

- app/abgeordnetenwatch.py 95.2% → 97.6%
  - test_rp_pattern_nr_wp_swap: '/538-18.pdf' → '18/538'
  - test_sn_pattern_dok_nr_leg_per_swap: 'dok_nr=2150&leg_per=8' → '8/2150'

Total: 47.59% → 48.69%, 666 → 686 Tests, 0 Failures.
2026-04-28 10:50:26 +02:00
Dotty Dotter
722b073bbd test(#134): wahlprogramm_fetch Coverage 42.8% → 54.4%
8 zusaetzliche Tests:
- TestLockFileRobustness: kaputtes JSON, fehlende Datei, _save_lock-Roundtrip
- TestLoadLinks: missing yaml + empty yaml (gestubbed)
- TestGetMissingProgrammes: leere/gefuellte Eintraege, Bundesland-Filter

yaml ist im Unit-Setup gestubbed; Tests patchen _load_links direkt
statt echte YAML-Parsing zu erzwingen — die echte Datei-Validierung
gehoert in die integration-Suite gegen die produktive links.yaml.
2026-04-28 08:42:29 +02:00
Dotty Dotter
8f3a811a83 test(#134): app/og_card.py Coverage 44% → 100%
10 Tests in test_og_card.py:
- TestCacheKey: deterministisch, aenderungs-empfindlich, 16 Zeichen lang
- TestGetCached: Pfad-Lookup mit/ohne Datei
- TestRenderOgCard: Cache-Hit vs Cache-Miss, URL-Encoding der DS,
  Playwright-Exception → None, cache_dir wird angelegt

Playwright wird ueber sys.modules-Stub eingehaengt, sync_playwright()
liefert einen ContextManager mit gemocktem Browser/Page-Stack — keine
echte Chromium-Installation noetig fuer den lokalen Run.

cache_key/get_cached-Tests waren bisher in test_wahlprogramm_fetch.py
verstreut; bleiben dort als Smoke, das eigentliche Modul-Test-File ist
jetzt test_og_card.py.
2026-04-28 08:40:20 +02:00
Dotty Dotter
50442f203a test(#134): build_pdf_href Coverage 50% → 100%
6 neue Tests in TestBuildPdfHref:
- explizite url wird unveraendert durchgereicht
- ohne url: WAHLPROGRAMME-Lookup ueber quelle-Feld
- ohne Seitenzahl in quelle → leerer href
- Quelle ohne WAHLPROGRAMME-Match → leerer href
- Query nutzt nur die ersten 5 Worte des Zitats
- Komma-Separator 'Titel, S. 17' parst genauso wie ' · S. 17'

app/redline_utils.py jetzt bei 100% Branch-Coverage.
2026-04-28 08:39:05 +02:00
Dotty Dotter
7de4df1fef feat(#126): protokoll_parsers/-Sub-Package + Registry-Pattern + ADR 0009
Architektur-Refactor zur Vorbereitung BL-uebergreifender Parser:

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

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

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

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

642 Tests gruen, kein Verhaltens-Drift.
2026-04-28 08:37:31 +02:00
Dotty Dotter
e26607854f feat(#106): Ingest-CLI fuer NRW-Plenarprotokolle
app/ingest_votes_nrw.py: Pipeline PDF → protokoll_parser_nrw → DB.

CLI:
  python -m app.ingest_votes_nrw --pdf /pfad/MMP18-119.pdf
  python -m app.ingest_votes_nrw --url https://landtag.nrw.de/.../MMP18-119.pdf
  python -m app.ingest_votes_nrw --pdf x.pdf --protokoll-id MMP18-119 --bundesland NRW

Protokoll-ID wird default aus Datei-Stem abgeleitet (MMP18-119.pdf →
MMP18-119), URL-Mode parst sie aus dem letzten Pfadsegment.

ingest_pdf() ist die programmatische API (auch fuer Folge-Cron, falls
spaeter automatisch Plenarprotokoll-Sammelinges nachgeruestet wird).
Statistik-Dict: parsed/written/skipped_no_drucksache/errors.

6 Tests: Roundtrip, skip-bei-fehlender-Drucksache, default + override
fuer Protokoll-ID, BL-Override (fuer #126-Folge), idempotenter Re-Ingest.
2026-04-28 08:03:18 +02:00
Dotty Dotter
ae3f48be41 feat(#106): plenum_vote_results-Tabelle + Repository
DB-Schema fuer fraktions-aggregierte Plenum-Abstimmungsergebnisse:
- bundesland, drucksache, quelle_protokoll als Compound-PK
  (eine Drucksache kann mehrfach abgestimmt werden — Ausschuss-Empfehlung
  und finale Beschlussfassung leben nebeneinander)
- ergebnis (angenommen/abgelehnt/ueberwiesen/...), einstimmig-Flag
- fraktionen_ja/_nein/_enthaltung als JSON-Arrays
- quelle_protokoll (z.B. 'MMP18-119') + optional quelle_url
- Index auf (bundesland, drucksache) fuer Lookup-Path

Repository-API:
- upsert_plenum_vote(...) idempotent ueber Compound-PK
- get_plenum_votes(bl, drucksache) → Liste, neueste zuerst

7 Tests fuer Roundtrip, einstimmig-Flag, Idempotenz, Multi-Protokoll-Erhalt,
leere Queries, Unicode-Handling von 'GRÜNE'.

Refs #106 — naechster Schritt: Ingest-CLI gegen NRW-PDFs.
2026-04-28 08:01:26 +02:00
Dotty Dotter
d640734641 feat(#106,#134): NRW-Protokoll-Parser v5 ins Repo migriert
Vorher als parser_v5_iteration15.py nur auf Prod-Server, nicht
versionskontrolliert. Jetzt unter app/protokoll_parser_nrw.py
mit klarem Naming-Schema (BL-Suffix, damit Folge-Adapter analog
heissen koennen, vgl. ADR 0002).

Aenderungen am Code:
- from __future__ import annotations (Py3.9-kompatibel fuer 'str | None')
- fitz-Import optional (try/except), damit pure-string-Funktionen
  auch im Stub-conftest funktionieren

30 Tests in test_protokoll_parser_nrw.py (#134 Phase 2):
- normalize_fraktionen: F.D.P., GRÜNE-Aliase, Landesregierung
- _is_empty_phrase: Niemand/Keine/nicht-Mustern
- _parse_vote_block: ja/nein-Extraktion plus Negationen
- find_results: angenommen/abgelehnt, einstimmig (nur ueber-Kind!),
  (neu)-Suffix in Drucksachen-Nrn, Sortierung, Dedup
- resolve_drucksache_for_ueber: Backward-Search mit closest-match

Refs #106 (Abstimmungsverhalten verknuepfen — Vorbereitung fuer DB-Schema)
Refs #126 (BL-uebergreifender Parser — NRW als Referenz-Implementierung)
Refs #134 (Test-Suite Audit — Phase 2)
2026-04-28 02:08:03 +02:00
Dotty Dotter
3a8c03db6c test(#134): test_wahlperioden.py — Datum→WP-Mapping
12 Tests fuer app/wahlperioden.py:
- aktuelle WP fuer Datum >= wahlperiode_start
- Vorgaenger-WP fuer Datum davor
- None bei unbekanntem BL
- Empty/None Datum → aktuelle WP (Default)
- Boundary-Tag (= start) gehoert zur neuen WP
- ISO-lexikographische Vergleichsannahme stimmt fuer alle BL
- all_wahlperioden() enthaelt aktuelle + Vorgaenger pro BL, keine Duplikate
2026-04-28 02:02:40 +02:00
Dotty Dotter
d2fc11f21b test(#134): test_rss.py — Atom-Feed-Validitaet, Filter, ETag, Limits
14 Tests fuer /api/feed.xml (#125):
- Atom-1.0 well-formed, Pflicht-Elemente vorhanden
- Entries nach updated_at DESC sortiert
- HTML-Escaping fuer Sonderzeichen (& in Titeln)
- Partei- und Bundesland-Filter wirken
- ETag-Header + 304 Not Modified
- Limit clamped auf [1, 200]
- Leere DB liefert gueltigen, aber leeren Feed
- CORS-Header gesetzt
- Self-URL enthaelt Filter-Parameter

Lokal skipped wenn app.main nicht importierbar (gleiche Konvention wie
test_endpoints_smoke.py); laeuft in Containern mit voller Deps.
2026-04-28 02:01:01 +02:00
Dotty Dotter
5559f42c92 feat(#138): SHA-Lock-File schuetzt vor stillem PDF-Tausch
Hintergrund: abgeordnetenwatch hatte das CDU-BE-2023-PDF unter dem alten
Slug-Namen gegen das CDU-BE-2026-Wahlprogramm ersetzt — ohne den
Datei-Namen zu aendern. Die Embedding-Indexierung haette das anachronistische
Programm uebernommen, ohne dass es jemand bemerkt.

Loesung: app/wahlprogramm-shas.lock.json pinnt nach erstem erfolgreichen
Download den SHA-256 jedes Programmes. Spaetere Aufrufe von
fetch_and_verify() vergleichen den Server-Inhalt gegen den Lock; bei
Abweichung wird abgebrochen mit klarer Fehlermeldung. Nur mit explizitem
Maintainer-Override (--accept-new-sha) wird der Lock aktualisiert.

CLI:
  python -m app.wahlprogramm_fetch --pin-existing
    seedet den Lock einmalig aus den vorhandenen PDFs (52 Eintraege).
  python -m app.wahlprogramm_fetch --fetch BL PARTEI [--accept-new-sha]
    laedt mit Lock-Pruefung; --accept-new-sha bei bewusstem Update.

6 neue Tests in test_wahlprogramm_fetch.py decken den Pferdetausch-
Block, das initiale Pinnen, das Migration-Szenario (PDF da, Lock leer)
und den --accept-new-sha-Override ab.

Closes #138
2026-04-28 01:58:42 +02:00
Dotty Dotter
09c29cac69 fix(#142): SL HTTP 5xx als Fehler raisen statt return []
Symptom: Monitoring-Scan zeigte bei SL seen=0 errors=OK, obwohl der
Umbraco-Backend HTTP 500 zurueckgab. Im _post_search wurde 5xx via
'logger.error + return []' geschluckt, sodass der Monitoring-Layer
die Fehlerursache nicht in monitoring_daily_summary persistierte.

Fix: bei resp.status_code != 200 httpx.HTTPStatusError raisen — das
propagiert durch search() ueber _search_adapter ins outer except in
daily_scan, das den Fehlertext in summary.errors schreibt.

Regression-Test test_search_propagates_http_500.

Closes #142
2026-04-28 01:46:35 +02:00
Dotty Dotter
a8d7b72702 feat(v2): Feedback-Widget mit Audit-Trail + Screenshot + direkter Gitea-Anbindung
- Component v2/components/feedback_widget.html: Button unten links oberhalb der
  Queue, Klick oeffnet Modal mit vorausgefuellten Kontext-Feldern (URL,
  Drucksache, Viewport, User-Agent, letzte 15 Klicks, letzte 10 Console-Errors,
  letzte 5 Page-Loads). Eingaben: Titel, Beschreibung, optional Screenshot
- Audit-Trail-Sammler in localStorage (Ringbuffer 30 Klicks, 10 Errors)
- Screenshot via self-hosted html2canvas 1.4.1 (194 KB unter app/static/v2/lib/)
- Backend POST /api/feedback (rate-limit 5/h):
  - validiert + html-strippt Inputs
  - erstellt Gitea-Issue per API mit Label 'feedback' (Label wird idempotent angelegt)
  - laedt Screenshot als Issue-Asset hoch (Gitea Issue-Attachment-API)
- 4 neue Settings: gitea_token, gitea_api_url, gitea_repo_owner, gitea_repo_name
- Server .env um GITEA_TOKEN ergaenzt
- 10 neue Unit-Tests (mit gemocktem httpx)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:00:44 +02:00
Dotty Dotter
2902164eff test: 467 -> 574 Tests (+107) — DDD, abgeordnetenwatch, monitoring, v2, Bug-Regressions
Neue Tests in dieser Migration:
- test_database.py (Merkliste-CRUD, Subscriptions, abgeordnetenwatch-Joins)
- test_clustering.py (82% Coverage)
- test_drucksache_typen.py (100%)
- test_mail.py (86%)
- test_monitoring.py (23 Tests)
- test_abgeordnetenwatch.py (23 Tests, inkl. Drucksache-Extraction)
- test_redline_parser.py (20 Tests fuer §INS§/§DEL§-Marker)
- test_bug_regressions.py (PRAGMA, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- test_embeddings_v3_v4.py (WRITE/READ-Pattern)
- test_wahlprogramm_check.py (#128)
- test_wahlprogramm_fetch.py (#138)
- test_antrag/bewertung/abonnement_repository.py + test_llm_bewerter.py (DDD)
- test_domain_behavior.py (5 Domain-Methoden boundary tests)
- tests/e2e/test_ui.py (Playwright)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
Dotty Dotter
f1a7da8544 Hybrid-Zitate: verified/unverified statt drop + UI-Labels
reconstruct_zitate droppt Zitate nicht mehr bei No-Match, sondern
markiert sie als verified=false. Das ist ehrlicher: paraphrasierte
Zitate sind wertvoller Kontext, sie brauchen nur ein visuelles
Unterscheidungsmerkmal.

UI:
- Verifizierte Zitate: grüner solid Border, "✓ verifiziert"
- Paraphrasierte Zitate: gelber dashed Border, "~ paraphrasiert
  (nicht wörtlich im Programm)"
- Warning-Text: "Zu diesem Themenkomplex konnten keine konkreten
  Formulierungen im Wahlprogramm gefunden werden"
- Antragsteller:in / Landesregierung als farbige Badges

Zitat-Model: neues Optional[bool] Feld "verified".

Tests: 206 passed (test_drops angepasst auf neues Verhalten).
2026-04-10 21:45:36 +02:00
Dotty Dotter
7ed2cca15f Tests: 8 Endpoint-Smoke-Tests (queue, auth, programme, health) 2026-04-10 20:09:34 +02:00
Dotty Dotter
9e341a695f Tests: 5 queue-Tests (enqueue, position, overflow, status) 2026-04-10 19:08:59 +02:00
Dotty Dotter
8bd311dbc8 Tests für auth.py: Token-Extraction, Auth-Enabled-Detection, _pick_best_title 2026-04-10 16:25:51 +02:00
Dotty Dotter
a821c19202 #47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:

Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment

Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.

Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
  drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
  triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
  durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
  Assessments die sich durch Re-Analyse ändern)

Tests: 194/194 grün.

Refs: #47, #60
2026-04-10 10:35:01 +02:00
Dotty Dotter
6f35efe4d7 #47: Volles PDF mit Highlight statt 1-Seiten-Extract
User-Feedback: "Kontext geht verloren wenn nur 1 Seite kommt".

Änderung: render_highlighted_page liefert jetzt das GESAMTE Wahlprogramm-
PDF mit gelber Highlight-Annotation auf der Fundstelle, statt eines
1-Seiten-Auszugs. Der Browser öffnet das vollständige Programm.

Frontend hängt #page=N an die URL → Browser scrollt direkt zur
Fundstelle. found_page wird als X-Found-Page Header mitgeliefert,
falls der Text auf einer anderen Seite als angefordert gefunden wurde
(Pre-#60 halluzinierte Seitennummern).

Return-Typ geändert: (bytes, int) statt bytes — zweiter Wert ist die
1-indexed Seitennummer wo der Treffer tatsächlich liegt.

Tests angepasst: Tuple-Unpacking, Size-Check entfernt (volles PDF ist
größer als 1-Seiten-Extract, der alte Vergleich war obsolet).

Refs: #47
2026-04-10 10:16:00 +02:00
Dotty Dotter
4ec6190416 #47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.

Implementation:

embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
  angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
  identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None

embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
  mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
  rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
  500-Zeichen-Snippets jedes Assessment-JSON auf

main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range

Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.

Tests: 194/194 grün (185 + 9 neue):

- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
  render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
  third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)

Refs: #47
2026-04-10 01:09:45 +02:00
Dotty Dotter
6ced7ae018 #60 Reopen — Option B: server-side reconstruct of zitat quelle/url
Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen
Halluzinations-Case gezeigt, den A+C nicht gefangen hat:

  BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche
  im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des
  Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen
  Q-IDs.

Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im
Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle
aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern,
solange wir der LLM-Selbstauskunft vertrauen.

Fix (Option B aus dem ursprünglichen Plan):

`embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im
analyzer **nach** json.loads aber **vor** Pydantic-Validation:

1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste.
2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks
   matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`,
   identische Logik wie Sub-D Test).
3. Match → quelle/url server-seitig durch _chunk_source_label und
   _chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN.
4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert).

Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt
keinen Pfad mehr zu "echter Text, falsche quelle".

Tests:
- TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei
  hallucinated, no-op bei leeren chunks, anchor-match-Fallback,
  short-needle und soft-hyphen Edge-Cases
- 185/185 grün (179 + 6 neu)

Refs: #60, #54 (Sub-D)
2026-04-09 22:52:17 +02:00
Dotty Dotter
db3ada9328 #60 Fix A+C: ENUM-basiertes Zitieren + top_k 2→5
Strukturelle Lösung für die LLM-Halluzinations-Cases aus #60:

A — ENUM-Anker
- format_quotes_for_prompt nummeriert jeden retrievten Chunk als [Q1], [Q2], …
- Neue ZITATEREGEL im Prompt erzwingt vier Bedingungen:
  1. Jedes Zitat MUSS auf genau einen [Qn]-Chunk verweisen
  2. Der text-String MUSS eine wörtliche, zusammenhängende Passage von
     min. 5 Wörtern aus genau diesem Chunk sein
  3. Die quelle MUSS exakt das Source-Label des gewählten Chunks sein
  4. Wenn kein Chunk passt: leeres zitate-Array — lieber 0 als erfunden
- analyzer.py:get_system_prompt: Wichtige-Regeln-Block zieht den selben
  Mechanismus nach, damit das LLM den [Qn]-Anker auch im System-Prompt
  sieht und nicht nur im User-Prompt.

C — Recall-Boost
- analyzer.py:run_analysis: top_k_per_partei 2 → 5. In den drei Cases
  aus #60 lagen die "richtigen" Seiten (S.36, S.37) bisher außerhalb
  des Top-3-Windows; mit Top-5 erhöht sich die Wahrscheinlichkeit, dass
  sie überhaupt im Kontext landen.

Hintergrund — die Halluzinationen waren KEIN Embedding-Bug:
Die retrievten Chunks für Case 1 enthielten S.58 (richtige Seite, falscher
Snippet) — das LLM hat den Snippet aus seinem Trainingswissen über
GRÜNE-Wahlprogramme rekonstruiert statt aus dem retrievten Chunk-Text zu
zitieren. Cases 2/3 hatten die zitierten Seiten gar nicht im Top-3-Window —
das LLM hat sowohl Seite als auch Snippet halluziniert. ENUM-Anker
verhindert beides strukturell, weil ein nicht-existenter [Qn] sofort
als Cheating sichtbar wäre.

Tests:
- test_chunks_get_enum_ids
- test_zitateregel_mentions_enum_anchor
- 179/179 grün

Refs: #60, #54 (Sub-D), #50 (Umbrella E2E)
2026-04-09 22:21:39 +02:00
Dotty Dotter
ed64399dbb Fix #60: NameError in get_relevant_quotes_for_antrag (Phase B refactor leftover)
Root cause: der #55-Refactor (eb045d0) hat in get_relevant_quotes_for_antrag
``partei_upper`` zu ``partei_lookup`` umbenannt — aber die Dict-Write-Zeile
``results[partei_upper] = ...`` wurde übersehen. Bei jedem Aufruf knallt seither
ein NameError, der in analyzer.py vom breiten ``except Exception`` verschluckt
und still auf die Keyword-Fallback-Suche umgeleitet wird. Konsequenz: 100% der
Assessments seit eb045d0 (inkl. autonomer Roadmap-Run #59) liefen ohne
Embedding-Retrieval — daher die LLM-Halluzinationen aus #60.

Fix:
- embeddings.py:528: partei_upper → partei_lookup
- analyzer.py:249: NameError/AttributeError/TypeError/KeyError nicht mehr
  schlucken. Programmierfehler im Embedding-Pfad sollen hart fehlschlagen,
  damit die nächste Refactor-Regression nicht wieder 24h still degradiert
  läuft. Echte Network-/API-Exceptions fallen weiterhin auf den
  Keyword-Pfad zurück.
- tests/test_embeddings.py: Regression-Test, der get_relevant_quotes_for_antrag
  mit gemockten chunks aufruft und sicherstellt, dass die Funktion nicht
  crasht und ein populiertes Result liefert. Hätte den Bug bei eb045d0
  sofort gefangen.

Refs: #60, #55, #59
2026-04-09 21:57:56 +02:00
Dotty Dotter
19e5fe4691 Phase J: SN EDAS-XML-Adapter (#26/#38) — Sachsen aktiv via XML-Export
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>
2026-04-09 14:39:03 +02:00
Dotty Dotter
278d74ff97 Phase I: HB PARiSHBAdapter (#21/#33) — Bremen aktiv
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>
2026-04-09 14:21:49 +02:00