2026-03-28 22:30:24 +01:00
""" Semantic search for Wahlprogramme and Parteiprogramme using Qwen embeddings. """
import json
#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
import re
2026-03-28 22:30:24 +01:00
import sqlite3
from pathlib import Path
from typing import Optional
import fitz # PyMuPDF
from openai import OpenAI
from . config import settings
# Embedding model
EMBEDDING_MODEL = " text-embedding-v3 "
EMBEDDING_DIMENSIONS = 1024
# Database path
EMBEDDINGS_DB = settings . data_dir / " embeddings.db "
# Programme definitions
PROGRAMME = {
# Wahlprogramme NRW 2022
" spd-nrw-2022 " : {
" name " : " SPD NRW Wahlprogramm 2022 " ,
" typ " : " wahlprogramm " ,
" partei " : " SPD " ,
" bundesland " : " NRW " ,
" pdf " : " spd-nrw-2022.pdf " ,
} ,
" cdu-nrw-2022 " : {
" name " : " CDU NRW Wahlprogramm 2022 " ,
" typ " : " wahlprogramm " ,
" partei " : " CDU " ,
" bundesland " : " NRW " ,
" pdf " : " cdu-nrw-2022.pdf " ,
} ,
" gruene-nrw-2022 " : {
" name " : " Grüne NRW Wahlprogramm 2022 " ,
" typ " : " wahlprogramm " ,
" partei " : " GRÜNE " ,
" bundesland " : " NRW " ,
" pdf " : " gruene-nrw-2022.pdf " ,
} ,
" fdp-nrw-2022 " : {
" name " : " FDP NRW Wahlprogramm 2022 " ,
" typ " : " wahlprogramm " ,
" partei " : " FDP " ,
" bundesland " : " NRW " ,
" pdf " : " fdp-nrw-2022.pdf " ,
} ,
" afd-nrw-2022 " : {
" name " : " AfD NRW Wahlprogramm 2022 " ,
" typ " : " wahlprogramm " ,
" partei " : " AfD " ,
" bundesland " : " NRW " ,
" pdf " : " afd-nrw-2022.pdf " ,
} ,
Activate LSA: Wahlprogramme + ingest + frontend (#2)
Brings Sachsen-Anhalt online as the second supported Bundesland after
NRW. Closes the gap that issue #2 left open: with the PortalaAdapter
already in place from c7242f8, this commit adds the reference data and
flips the activation switch.
Wahlprogramme (LTW Sachsen-Anhalt 06.06.2021)
- Six PDFs added under app/static/referenzen/{cdu,spd,gruene,fdp,afd,
linke}-lsa-2021.pdf, plus paged plain-text extractions under
app/kontext/*.txt for the keyword fallback search.
- Sources verified by hand:
- CDU "Unsere Heimat. Unsere Verantwortung." (cdulsa.de, 82 pages)
- SPD "Zusammenhalt und neue Chancen" (FES library, 77 pages)
- GRÜNE "Verlässlich für Sachsen-Anhalt" (gruene-lsa.de, 164 pages)
- FDP "Wahlprogramm zur Landtagswahl 2021" (Naumann-Stiftung, 76 pages)
- AfD "Alles für unsere Heimat!" (klimawahlen.de mirror, 64 pages)
- LINKE "Wahlprogramm zur Landtagswahl 2021" (dielinke-sachsen-anhalt.de,
88 pages)
- The CDU PDF was the trickiest: KAS blocks bot downloads via
Cloudflare; the cdulsa.de copy was located by an autonomous web
search and verified to be byte-identical with the official document.
Embeddings indexed (in production container, OpenAI-compatible
DashScope embeddings via the existing index_programm pipeline):
- CDU 134, SPD 145, GRÜNE 183, FDP 100, AfD 64, LINKE 143 chunks
- Total LSA: 769 new chunks alongside the existing 775 NRW chunks
and 335 federal Grundsatzprogramm chunks.
wahlprogramme.py
- WAHLPROGRAMME["LSA"] populated with all six parties (canonical fraction
codes, original titles, page counts).
embeddings.py
- PROGRAMME extended with the six new "<partei>-lsa-2021" entries that
the indexer pipeline expects.
bundeslaender.py
- LSA flipped to aktiv=True. The frontend dropdown will now offer
Sachsen-Anhalt as a selectable bundesland and analyzer.get_bundesland_
context() will produce a real LSA prompt block (CDU/SPD/FDP as
governing fractions, all six landtagsfraktionen).
End-to-end smoke test (live in production container before commit)
- Adapter: PortalaAdapter.search() returned current Anträge of März 2026
(LINKE + GRÜNE) with correct titles and PDF URLs.
- Semantic search for an LSA "ÖPNV in der Altmark" sample antrag
matched LINKE S.53, SPD S.68, FDP S.52 — all three with similarity
> 0.6 and topical hits (Regionalisierungsmittel, ÖPNV-Förderprogramm,
Wasserstoffnetz).
Resolves issue #2.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:12:32 +02:00
# Sachsen-Anhalt (LTW 2021)
" cdu-lsa-2021 " : {
" name " : " CDU Sachsen-Anhalt Regierungsprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " CDU " ,
" bundesland " : " LSA " ,
" pdf " : " cdu-lsa-2021.pdf " ,
} ,
" spd-lsa-2021 " : {
" name " : " SPD Sachsen-Anhalt Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " SPD " ,
" bundesland " : " LSA " ,
" pdf " : " spd-lsa-2021.pdf " ,
} ,
" gruene-lsa-2021 " : {
" name " : " Grüne Sachsen-Anhalt Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " GRÜNE " ,
" bundesland " : " LSA " ,
" pdf " : " gruene-lsa-2021.pdf " ,
} ,
" fdp-lsa-2021 " : {
" name " : " FDP Sachsen-Anhalt Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " FDP " ,
" bundesland " : " LSA " ,
" pdf " : " fdp-lsa-2021.pdf " ,
} ,
" afd-lsa-2021 " : {
" name " : " AfD Sachsen-Anhalt Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " AfD " ,
" bundesland " : " LSA " ,
" pdf " : " afd-lsa-2021.pdf " ,
} ,
" linke-lsa-2021 " : {
" name " : " DIE LINKE Sachsen-Anhalt Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " LINKE " ,
" bundesland " : " LSA " ,
" pdf " : " linke-lsa-2021.pdf " ,
} ,
Add MV+BE Wahlprogramme zur jeweils laufenden Legislatur (#4, #10)
11 PDFs in app/static/referenzen/ + Einträge in WAHLPROGRAMME
und embeddings.PROGRAMME für die beiden bisher nur per
föderalem Grundsatzprogramm-Fallback abgedeckten Landtage:
- **MV** (WP 8, seit 26.10.2021): CDU, SPD, GRÜNE, FDP, AfD, LINKE
Wahlprogramme zur LTW 26.09.2021. Issue #4.
- **BE** (WP 19, konstituiert nach Wiederholungswahl 12.02.2023):
CDU, SPD, GRÜNE, LINKE, AfD Programme zur AGH-Wahl 26.09.2021.
Die Wiederholungswahl 2023 nutzte dieselben Programme wie die
Originalwahl, daher die "be-2023.pdf"-Benennung mit Programm-
jahr 2021. Issue #10.
Quellen: abgeordnetenwatch.de Mirror für 9 PDFs, library.fes.de
für SPD MV, cdu-mv.de direkt für CDU MV, fdp-mv.de direkt für
FDP MV. Alle PDFs verifiziert via pdftotext gegen das im Programm
genannte Wahldatum, um zu vermeiden, dass aktuellere
Wahlkampf-Entwürfe (z.B. das CDU "Berlin-Plan 2026") als
Legislatur-Programm fehlinterpretiert werden.
Indexierung in die embeddings-DB ist NICHT Teil dieses Commits —
sie muss separat im prod-Container ausgeführt werden:
docker exec gwoe-antragspruefer python -c "
from app.embeddings import index_programm
from pathlib import Path
d = Path('/app/static/referenzen')
for pid in ['cdu-mv-2021','spd-mv-2021','gruene-mv-2021',
'fdp-mv-2021','afd-mv-2021','linke-mv-2021',
'cdu-be-2023','spd-be-2023','gruene-be-2023',
'linke-be-2023','afd-be-2023']:
index_programm(pid, d)
"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:24:33 +02:00
# Mecklenburg-Vorpommern (LTW 26.09.2021, WP 8) — Issue #4
" cdu-mv-2021 " : {
" name " : " CDU Mecklenburg-Vorpommern Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " CDU " ,
" bundesland " : " MV " ,
" pdf " : " cdu-mv-2021.pdf " ,
} ,
" spd-mv-2021 " : {
" name " : " SPD Mecklenburg-Vorpommern Regierungsprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " SPD " ,
" bundesland " : " MV " ,
" pdf " : " spd-mv-2021.pdf " ,
} ,
" gruene-mv-2021 " : {
" name " : " Grüne Mecklenburg-Vorpommern Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " GRÜNE " ,
" bundesland " : " MV " ,
" pdf " : " gruene-mv-2021.pdf " ,
} ,
" fdp-mv-2021 " : {
" name " : " FDP Mecklenburg-Vorpommern Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " FDP " ,
" bundesland " : " MV " ,
" pdf " : " fdp-mv-2021.pdf " ,
} ,
" afd-mv-2021 " : {
" name " : " AfD Mecklenburg-Vorpommern Landeswahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " AfD " ,
" bundesland " : " MV " ,
" pdf " : " afd-mv-2021.pdf " ,
} ,
" linke-mv-2021 " : {
" name " : " DIE LINKE Mecklenburg-Vorpommern Zukunftsprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " LINKE " ,
" bundesland " : " MV " ,
" pdf " : " linke-mv-2021.pdf " ,
} ,
# Berlin (AGH-Wahl 26.09.2021, Wiederholung 12.02.2023, WP 19) —
# Issue #10. Programme stammen aus dem Wahlkampf 2021 — die
# Wiederholungswahl 2023 nutzte dieselben Programme.
" cdu-be-2023 " : {
" name " : " CDU Berlin Berlin-Plan 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " CDU " ,
" bundesland " : " BE " ,
" pdf " : " cdu-be-2023.pdf " ,
} ,
" spd-be-2023 " : {
" name " : " SPD Berlin Wahlprogramm AGH 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " SPD " ,
" bundesland " : " BE " ,
" pdf " : " spd-be-2023.pdf " ,
} ,
" gruene-be-2023 " : {
" name " : " Grüne Berlin Landeswahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " GRÜNE " ,
" bundesland " : " BE " ,
" pdf " : " gruene-be-2023.pdf " ,
} ,
" linke-be-2023 " : {
" name " : " DIE LINKE Berlin Wahlprogramm 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " LINKE " ,
" bundesland " : " BE " ,
" pdf " : " linke-be-2023.pdf " ,
} ,
" afd-be-2023 " : {
" name " : " AfD Berlin Wahlprogramm AGH 2021 " ,
" typ " : " wahlprogramm " ,
" partei " : " AfD " ,
" bundesland " : " BE " ,
" pdf " : " afd-be-2023.pdf " ,
} ,
Add 30 Wahlprogramme für TH/BB/HH/SH/BW/RP (#37, #39, #40, #32, #41, #42)
Sechs der zehn aktiven Bundesländer hatten bisher keine Wahlprogramme
indexiert (alle sechs heute neu aktiviert: BW/HH/TH in Phase 1, SH/BB/RP
in Phase 2). Antrag-Analysen für diese BL fielen damit auf föderale
Grundsatzprogramme als Fallback zurück.
Beschafft via abgeordnetenwatch.de für die jeweils laufende WP:
- TH WP8 (LTW 01.09.2024): CDU, AfD, LINKE, BSW, SPD — 5 PDFs
- BB WP8 (LTW 22.09.2024): SPD, AfD, CDU, BSW — 4 PDFs
- HH WP23 (Bürgerschaftswahl 02.03.2025): SPD, CDU, GRÜNE, LINKE, AfD — 5 PDFs
- SH WP20 (LTW 08.05.2022): CDU, SPD, GRÜNE, FDP, SSW — 5 PDFs
- BW WP17 (LTW 14.03.2021): GRÜNE, CDU, AfD, SPD, FDP — 5 PDFs
- RP WP18 (LTW 14.03.2021): SPD, CDU, AfD, GRÜNE, FREIE WÄHLER, FDP — 6 PDFs
Insgesamt 30 PDFs in app/static/referenzen/, plus 30 Einträge in
WAHLPROGRAMME[bl][partei] und embeddings.PROGRAMME.
Naming-Schema wie etabliert: <partei>-<bl>-<jahr>.pdf, also
spd-th-2024.pdf, fw-rp-2021.pdf etc.
Wichtig zu Memory feedback_legislaturprogramme: alle BL nutzen das
Programm der LAUFENDEN Wahlperiode, NICHT Programme aus späteren
Wahlen. BW und RP wählen am 08.03.2026 / 22.03.2026 neu — der
18./19. Landtag konstituiert sich erst, daher sind die 17./18. WP
mit den 2021er Programmen weiterhin laufend bis zur Konstituierung.
Indexierung im prod-Container ist NICHT Teil dieses Commits — muss
separat ausgeführt werden:
ssh vserver 'docker exec gwoe-antragspruefer python -c "
from app.embeddings import index_programm
from pathlib import Path
d = Path(\"/app/app/static/referenzen\")
for pid in [
\"cdu-th-2024\",\"afd-th-2024\",\"linke-th-2024\",\"bsw-th-2024\",\"spd-th-2024\",
\"spd-bb-2024\",\"afd-bb-2024\",\"cdu-bb-2024\",\"bsw-bb-2024\",
\"spd-hh-2025\",\"cdu-hh-2025\",\"gruene-hh-2025\",\"linke-hh-2025\",\"afd-hh-2025\",
\"cdu-sh-2022\",\"spd-sh-2022\",\"gruene-sh-2022\",\"fdp-sh-2022\",\"ssw-sh-2022\",
\"gruene-bw-2021\",\"cdu-bw-2021\",\"afd-bw-2021\",\"spd-bw-2021\",\"fdp-bw-2021\",
\"spd-rp-2021\",\"cdu-rp-2021\",\"afd-rp-2021\",\"gruene-rp-2021\",\"fw-rp-2021\",\"fdp-rp-2021\",
]:
index_programm(pid, d)
"'
77 pytest tests passing — der File-Existenz-Check in test_wahlprogramme.py
hätte einen Tippfehler im PDF-Namen sofort gefangen.
Erledigt UI-Aktivierungs-Issues #37 (TH), #39 (BB), #40 (HH), #32 (SH),
#41 (BW), #42 (RP).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:03:11 +02:00
# Thüringen — LTW 01.09.2024, WP 8 (Issue #37)
" cdu-th-2024 " : { " name " : " CDU Thüringen Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " CDU " , " bundesland " : " TH " , " pdf " : " cdu-th-2024.pdf " } ,
" afd-th-2024 " : { " name " : " AfD Thüringen Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " AfD " , " bundesland " : " TH " , " pdf " : " afd-th-2024.pdf " } ,
" linke-th-2024 " : { " name " : " DIE LINKE Thüringen Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " LINKE " , " bundesland " : " TH " , " pdf " : " linke-th-2024.pdf " } ,
" bsw-th-2024 " : { " name " : " BSW Thüringen Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " BSW " , " bundesland " : " TH " , " pdf " : " bsw-th-2024.pdf " } ,
" spd-th-2024 " : { " name " : " SPD Thüringen Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " SPD " , " bundesland " : " TH " , " pdf " : " spd-th-2024.pdf " } ,
# Brandenburg — LTW 22.09.2024, WP 8 (Issue #39)
" spd-bb-2024 " : { " name " : " SPD Brandenburg Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " SPD " , " bundesland " : " BB " , " pdf " : " spd-bb-2024.pdf " } ,
" afd-bb-2024 " : { " name " : " AfD Brandenburg Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " AfD " , " bundesland " : " BB " , " pdf " : " afd-bb-2024.pdf " } ,
" cdu-bb-2024 " : { " name " : " CDU Brandenburg Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " CDU " , " bundesland " : " BB " , " pdf " : " cdu-bb-2024.pdf " } ,
" bsw-bb-2024 " : { " name " : " BSW Brandenburg Wahlprogramm 2024 " , " typ " : " wahlprogramm " , " partei " : " BSW " , " bundesland " : " BB " , " pdf " : " bsw-bb-2024.pdf " } ,
# Hamburg — Bürgerschaftswahl 02.03.2025, WP 23 (Issue #40)
" spd-hh-2025 " : { " name " : " SPD Hamburg Wahlprogramm 2025 " , " typ " : " wahlprogramm " , " partei " : " SPD " , " bundesland " : " HH " , " pdf " : " spd-hh-2025.pdf " } ,
" cdu-hh-2025 " : { " name " : " CDU Hamburg Wahlprogramm 2025 " , " typ " : " wahlprogramm " , " partei " : " CDU " , " bundesland " : " HH " , " pdf " : " cdu-hh-2025.pdf " } ,
" gruene-hh-2025 " : { " name " : " Grüne Hamburg Regierungsprogramm 2025 " , " typ " : " wahlprogramm " , " partei " : " GRÜNE " , " bundesland " : " HH " , " pdf " : " gruene-hh-2025.pdf " } ,
" linke-hh-2025 " : { " name " : " DIE LINKE Hamburg Wahlprogramm 2025 " , " typ " : " wahlprogramm " , " partei " : " LINKE " , " bundesland " : " HH " , " pdf " : " linke-hh-2025.pdf " } ,
" afd-hh-2025 " : { " name " : " AfD Hamburg Wahlprogramm 2025 " , " typ " : " wahlprogramm " , " partei " : " AfD " , " bundesland " : " HH " , " pdf " : " afd-hh-2025.pdf " } ,
# Schleswig-Holstein — LTW 08.05.2022, WP 20 (Issue #32)
" cdu-sh-2022 " : { " name " : " CDU Schleswig-Holstein Wahlprogramm 2022 " , " typ " : " wahlprogramm " , " partei " : " CDU " , " bundesland " : " SH " , " pdf " : " cdu-sh-2022.pdf " } ,
" spd-sh-2022 " : { " name " : " SPD Schleswig-Holstein Wahlprogramm 2022 " , " typ " : " wahlprogramm " , " partei " : " SPD " , " bundesland " : " SH " , " pdf " : " spd-sh-2022.pdf " } ,
" gruene-sh-2022 " : { " name " : " Grüne Schleswig-Holstein Wahlprogramm 2022 " , " typ " : " wahlprogramm " , " partei " : " GRÜNE " , " bundesland " : " SH " , " pdf " : " gruene-sh-2022.pdf " } ,
" fdp-sh-2022 " : { " name " : " FDP Schleswig-Holstein Wahlprogramm 2022 " , " typ " : " wahlprogramm " , " partei " : " FDP " , " bundesland " : " SH " , " pdf " : " fdp-sh-2022.pdf " } ,
" ssw-sh-2022 " : { " name " : " SSW Schleswig-Holstein Wahlprogramm 2022 " , " typ " : " wahlprogramm " , " partei " : " SSW " , " bundesland " : " SH " , " pdf " : " ssw-sh-2022.pdf " } ,
# Baden-Württemberg — LTW 14.03.2021, WP 17 (Issue #41)
" gruene-bw-2021 " : { " name " : " Grüne Baden-Württemberg Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " GRÜNE " , " bundesland " : " BW " , " pdf " : " gruene-bw-2021.pdf " } ,
" cdu-bw-2021 " : { " name " : " CDU Baden-Württemberg Regierungsprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " CDU " , " bundesland " : " BW " , " pdf " : " cdu-bw-2021.pdf " } ,
" afd-bw-2021 " : { " name " : " AfD Baden-Württemberg Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " AfD " , " bundesland " : " BW " , " pdf " : " afd-bw-2021.pdf " } ,
" spd-bw-2021 " : { " name " : " SPD Baden-Württemberg Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " SPD " , " bundesland " : " BW " , " pdf " : " spd-bw-2021.pdf " } ,
" fdp-bw-2021 " : { " name " : " FDP Baden-Württemberg Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " FDP " , " bundesland " : " BW " , " pdf " : " fdp-bw-2021.pdf " } ,
# Rheinland-Pfalz — LTW 14.03.2021, WP 18 (Issue #42)
" spd-rp-2021 " : { " name " : " SPD Rheinland-Pfalz Regierungsprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " SPD " , " bundesland " : " RP " , " pdf " : " spd-rp-2021.pdf " } ,
" cdu-rp-2021 " : { " name " : " CDU Rheinland-Pfalz Regierungsprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " CDU " , " bundesland " : " RP " , " pdf " : " cdu-rp-2021.pdf " } ,
" afd-rp-2021 " : { " name " : " AfD Rheinland-Pfalz Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " AfD " , " bundesland " : " RP " , " pdf " : " afd-rp-2021.pdf " } ,
" gruene-rp-2021 " : { " name " : " Grüne Rheinland-Pfalz Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " GRÜNE " , " bundesland " : " RP " , " pdf " : " gruene-rp-2021.pdf " } ,
" fw-rp-2021 " : { " name " : " FREIE WÄHLER Rheinland-Pfalz Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " FREIE WÄHLER " , " bundesland " : " RP " , " pdf " : " fw-rp-2021.pdf " } ,
" fdp-rp-2021 " : { " name " : " FDP Rheinland-Pfalz Wahlprogramm 2021 " , " typ " : " wahlprogramm " , " partei " : " FDP " , " bundesland " : " RP " , " pdf " : " fdp-rp-2021.pdf " } ,
2026-03-28 22:30:24 +01:00
# Grundsatzprogramme (Bund)
" spd-grundsatz " : {
" name " : " SPD Grundsatzprogramm 2007 " ,
" typ " : " parteiprogramm " ,
" partei " : " SPD " ,
" pdf " : " spd-grundsatzprogramm.pdf " ,
} ,
" cdu-grundsatz " : {
" name " : " CDU Grundsatzprogramm 2007 " ,
" typ " : " parteiprogramm " ,
" partei " : " CDU " ,
" pdf " : " cdu-grundsatzprogramm.pdf " ,
} ,
" gruene-grundsatz " : {
" name " : " Grüne Grundsatzprogramm 2020 " ,
" typ " : " parteiprogramm " ,
" partei " : " GRÜNE " ,
" pdf " : " gruene-grundsatzprogramm.pdf " ,
} ,
" fdp-grundsatz " : {
" name " : " FDP Grundsatzprogramm 2012 " ,
" typ " : " parteiprogramm " ,
" partei " : " FDP " ,
" pdf " : " fdp-grundsatzprogramm.pdf " ,
} ,
}
def init_embeddings_db ( ) :
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
""" Initialize the embeddings database.
Includes a forward - only migration step ( Issue #5): adds the
` ` bundesland ` ` column if missing and backfills existing rows from the
` ` PROGRAMME ` ` registry . Grundsatzprogramme ( federal level ) keep
` ` bundesland = NULL ` ` ; the ` ` find_relevant_chunks ` ` query treats NULL
as " matches any state " .
"""
2026-03-28 22:30:24 +01:00
conn = sqlite3 . connect ( EMBEDDINGS_DB )
conn . execute ( """
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY ,
programm_id TEXT NOT NULL ,
partei TEXT NOT NULL ,
typ TEXT NOT NULL ,
seite INTEGER ,
text TEXT NOT NULL ,
embedding BLOB NOT NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""" )
conn . execute ( " CREATE INDEX IF NOT EXISTS idx_chunks_partei ON chunks(partei) " )
conn . execute ( " CREATE INDEX IF NOT EXISTS idx_chunks_typ ON chunks(typ) " )
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
# Migration: bundesland-Spalte ergänzen, falls Tabelle aus Pre-#5-Zeit
cols = { row [ 1 ] for row in conn . execute ( " PRAGMA table_info(chunks) " ) . fetchall ( ) }
if " bundesland " not in cols :
conn . execute ( " ALTER TABLE chunks ADD COLUMN bundesland TEXT " )
conn . execute ( " CREATE INDEX IF NOT EXISTS idx_chunks_bundesland ON chunks(bundesland) " )
# Backfill: Bundesland aus PROGRAMME-Registry für bestehende Zeilen
# nachtragen. Grundsatzprogramme bleiben NULL.
for prog_id , info in PROGRAMME . items ( ) :
bl = info . get ( " bundesland " )
if bl is not None :
conn . execute (
" UPDATE chunks SET bundesland = ? WHERE programm_id = ? AND bundesland IS NULL " ,
( bl , prog_id ) ,
)
2026-03-28 22:30:24 +01:00
conn . commit ( )
conn . close ( )
def get_client ( ) - > OpenAI :
""" Get DashScope client. """
return OpenAI (
api_key = settings . dashscope_api_key ,
base_url = settings . dashscope_base_url ,
)
def create_embedding ( text : str ) - > list [ float ] :
""" Create embedding for text using Qwen. """
client = get_client ( )
response = client . embeddings . create (
model = EMBEDDING_MODEL ,
input = text ,
dimensions = EMBEDDING_DIMENSIONS ,
)
return response . data [ 0 ] . embedding
def chunk_text ( text : str , chunk_size : int = 500 , overlap : int = 50 ) - > list [ str ] :
""" Split text into overlapping chunks by words. """
words = text . split ( )
chunks = [ ]
i = 0
while i < len ( words ) :
chunk_words = words [ i : i + chunk_size ]
chunk = " " . join ( chunk_words )
if chunk . strip ( ) :
chunks . append ( chunk )
i + = chunk_size - overlap
return chunks
def extract_text_with_pages ( pdf_path : Path ) - > list [ tuple [ int , str ] ] :
""" Extract text from PDF with page numbers. """
doc = fitz . open ( pdf_path )
pages = [ ]
for page_num in range ( len ( doc ) ) :
page = doc [ page_num ]
text = page . get_text ( )
if text . strip ( ) :
pages . append ( ( page_num + 1 , text ) )
doc . close ( )
return pages
def index_programm ( programm_id : str , pdf_dir : Path ) - > int :
""" Index a single program PDF into embeddings database. """
if programm_id not in PROGRAMME :
raise ValueError ( f " Unknown program: { programm_id } " )
info = PROGRAMME [ programm_id ]
pdf_path = pdf_dir / info [ " pdf " ]
if not pdf_path . exists ( ) :
print ( f " PDF not found: { pdf_path } " )
return 0
conn = sqlite3 . connect ( EMBEDDINGS_DB )
# Remove existing chunks for this program
conn . execute ( " DELETE FROM chunks WHERE programm_id = ? " , ( programm_id , ) )
# Extract and chunk
pages = extract_text_with_pages ( pdf_path )
total_chunks = 0
for page_num , page_text in pages :
chunks = chunk_text ( page_text , chunk_size = 400 , overlap = 50 )
for chunk_text_content in chunks :
if len ( chunk_text_content . split ( ) ) < 20 : # Skip tiny chunks
continue
try :
embedding = create_embedding ( chunk_text_content )
embedding_blob = json . dumps ( embedding ) . encode ( )
conn . execute ( """
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
INSERT INTO chunks ( programm_id , partei , typ , seite , text , embedding , bundesland )
VALUES ( ? , ? , ? , ? , ? , ? , ? )
2026-03-28 22:30:24 +01:00
""" , (
programm_id ,
info [ " partei " ] ,
info [ " typ " ] ,
page_num ,
chunk_text_content ,
embedding_blob ,
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
info . get ( " bundesland " ) , # NULL für Grundsatzprogramme
2026-03-28 22:30:24 +01:00
) )
total_chunks + = 1
except Exception as e :
print ( f " Error embedding chunk: { e } " )
continue
conn . commit ( )
conn . close ( )
print ( f " Indexed { total_chunks } chunks from { programm_id } " )
return total_chunks
def cosine_similarity ( a : list [ float ] , b : list [ float ] ) - > float :
""" Calculate cosine similarity between two vectors. """
dot = sum ( x * y for x , y in zip ( a , b ) )
norm_a = sum ( x * x for x in a ) * * 0.5
norm_b = sum ( x * x for x in b ) * * 0.5
if norm_a == 0 or norm_b == 0 :
return 0.0
return dot / ( norm_a * norm_b )
def find_relevant_chunks (
query : str ,
parteien : list [ str ] = None ,
typ : str = None ,
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
bundesland : str = None ,
2026-03-28 22:30:24 +01:00
top_k : int = 3 ,
min_similarity : float = 0.5 ,
) - > list [ dict ] :
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
""" Find most relevant chunks for a query.
Args :
bundesland : Wenn gesetzt , werden nur Chunks dieses Bundeslands ODER
globale Chunks ( bundesland IS NULL , z . B . Grundsatzprogramme )
berücksichtigt . Wenn None , kein Filter .
"""
2026-03-28 22:30:24 +01:00
query_embedding = create_embedding ( query )
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
2026-03-28 22:30:24 +01:00
conn = sqlite3 . connect ( EMBEDDINGS_DB )
conn . row_factory = sqlite3 . Row
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
2026-03-28 22:30:24 +01:00
# Build query
sql = " SELECT * FROM chunks WHERE 1=1 "
params = [ ]
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
2026-03-28 22:30:24 +01:00
if parteien :
placeholders = " , " . join ( " ? " * len ( parteien ) )
sql + = f " AND partei IN ( { placeholders } ) "
params . extend ( parteien )
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
2026-03-28 22:30:24 +01:00
if typ :
sql + = " AND typ = ? "
params . append ( typ )
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
if bundesland :
# Bundesland-spezifische ODER globale Chunks (Grundsatzprogramme).
sql + = " AND (bundesland = ? OR bundesland IS NULL) "
params . append ( bundesland )
2026-03-28 22:30:24 +01:00
rows = conn . execute ( sql , params ) . fetchall ( )
conn . close ( )
# Calculate similarities
results = [ ]
for row in rows :
chunk_embedding = json . loads ( row [ " embedding " ] )
similarity = cosine_similarity ( query_embedding , chunk_embedding )
if similarity > = min_similarity :
results . append ( {
" programm_id " : row [ " programm_id " ] ,
" partei " : row [ " partei " ] ,
" typ " : row [ " typ " ] ,
" seite " : row [ " seite " ] ,
" text " : row [ " text " ] ,
" similarity " : similarity ,
} )
# Sort by similarity and return top_k
results . sort ( key = lambda x : x [ " similarity " ] , reverse = True )
return results [ : top_k ]
def get_relevant_quotes_for_antrag (
antrag_text : str ,
fraktionen : list [ str ] ,
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
bundesland : str ,
2026-03-28 22:30:24 +01:00
top_k_per_partei : int = 2 ,
) - > dict [ str , list [ dict ] ] :
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
""" Get relevant quotes from Wahl- and Parteiprogramme for an Antrag.
Args :
bundesland : Pflicht . Bestimmt , welche Wahlprogramme durchsucht werden
und welche Regierungsfraktionen zusätzlich zu den Antragstellern
einbezogen werden .
"""
# Lokaler Import vermeidet Zirkularität: bundeslaender.py importiert nichts
# aus diesem Modul, aber der saubere Trennstrich bleibt erhalten.
from . bundeslaender import BUNDESLAENDER
if bundesland not in BUNDESLAENDER :
raise ValueError ( f " Unbekanntes Bundesland: { bundesland } " )
regierungsfraktionen = BUNDESLAENDER [ bundesland ] . regierungsfraktionen
parteien_to_search = list ( dict . fromkeys ( fraktionen + regierungsfraktionen ) ) # dedupe, Reihenfolge stabil
2026-03-28 22:30:24 +01:00
results = { }
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
Phase B: Parteinamen-Mapper #55 (Roadmap #59)
Zentrale `app/parteien.py` als Single Source of Truth für die Partei-
Auflösung:
- `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen
bekannten Aliasen, optionalem `bundesland_scope` und Government-
Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW,
BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der
generische FREIE WÄHLER-Eintrag).
- `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups
mit Government-Vorrang und FW-Familien-Disambiguierung
- `extract_fraktionen(text, *, bundesland=None)` als Funnel für die
vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer-
partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope-
Filter (SSW nur in SH, BVB-FW nur in BB, etc.).
- `display_name(canonical, *, long=False)` für UI/PDF — kurze Form
bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN"
statt "GRÜNE" etc.
Adapter-Migration in `app/parlamente.py`:
- Vier nahezu identische `_normalize_fraktion()`-Methoden in
PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter
durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit
`self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt.
- `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen
für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`,
also keine Call-Site-Änderung nötig.
`app/embeddings.py:496` Workaround-Hack entfernt:
- `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen
`normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen
dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und
Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise
überall garantiert kanonisch.
Tests:
- Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW-
Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion,
Government-Marker, Tabellen-Konsistenz
- `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6
statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4
Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter
BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation
157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die
kanonischen Keys sind exakt die in der DB stehenden Strings, kein
Migrations-Schritt nötig.
Refs: #55, #59 (Phase B)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:13 +02:00
from . parteien import normalize_partei
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
for partei in parteien_to_search :
Phase B: Parteinamen-Mapper #55 (Roadmap #59)
Zentrale `app/parteien.py` als Single Source of Truth für die Partei-
Auflösung:
- `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen
bekannten Aliasen, optionalem `bundesland_scope` und Government-
Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW,
BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der
generische FREIE WÄHLER-Eintrag).
- `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups
mit Government-Vorrang und FW-Familien-Disambiguierung
- `extract_fraktionen(text, *, bundesland=None)` als Funnel für die
vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer-
partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope-
Filter (SSW nur in SH, BVB-FW nur in BB, etc.).
- `display_name(canonical, *, long=False)` für UI/PDF — kurze Form
bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN"
statt "GRÜNE" etc.
Adapter-Migration in `app/parlamente.py`:
- Vier nahezu identische `_normalize_fraktion()`-Methoden in
PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter
durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit
`self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt.
- `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen
für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`,
also keine Call-Site-Änderung nötig.
`app/embeddings.py:496` Workaround-Hack entfernt:
- `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen
`normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen
dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und
Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise
überall garantiert kanonisch.
Tests:
- Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW-
Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion,
Government-Marker, Tabellen-Konsistenz
- `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6
statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4
Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter
BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation
157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die
kanonischen Keys sind exakt die in der DB stehenden Strings, kein
Migrations-Schritt nötig.
Refs: #55, #59 (Phase B)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:13 +02:00
# Kanonischer Lookup-Key über den zentralen Mapper (#55). Ersetzt
# den alten Hack ``partei.upper() if partei != "GRÜNE" else "GRÜNE"``,
# der nur die Schreibweisen-Drift in einer einzigen Partei
# abgefangen hat. Wenn der Mapper nichts findet, fallen wir auf
# den Originalstring zurück — die DB-Lookup-Schicht macht ohnehin
# eigene Case-insensitive-Vergleiche.
canonical = normalize_partei ( partei , bundesland = bundesland )
partei_lookup = canonical or partei
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
# Wahlprogramm — bundesland-gefiltert
2026-03-28 22:30:24 +01:00
wahl_chunks = find_relevant_chunks (
antrag_text ,
Phase B: Parteinamen-Mapper #55 (Roadmap #59)
Zentrale `app/parteien.py` als Single Source of Truth für die Partei-
Auflösung:
- `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen
bekannten Aliasen, optionalem `bundesland_scope` und Government-
Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW,
BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der
generische FREIE WÄHLER-Eintrag).
- `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups
mit Government-Vorrang und FW-Familien-Disambiguierung
- `extract_fraktionen(text, *, bundesland=None)` als Funnel für die
vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer-
partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope-
Filter (SSW nur in SH, BVB-FW nur in BB, etc.).
- `display_name(canonical, *, long=False)` für UI/PDF — kurze Form
bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN"
statt "GRÜNE" etc.
Adapter-Migration in `app/parlamente.py`:
- Vier nahezu identische `_normalize_fraktion()`-Methoden in
PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter
durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit
`self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt.
- `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen
für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`,
also keine Call-Site-Änderung nötig.
`app/embeddings.py:496` Workaround-Hack entfernt:
- `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen
`normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen
dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und
Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise
überall garantiert kanonisch.
Tests:
- Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW-
Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion,
Government-Marker, Tabellen-Konsistenz
- `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6
statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4
Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter
BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation
157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die
kanonischen Keys sind exakt die in der DB stehenden Strings, kein
Migrations-Schritt nötig.
Refs: #55, #59 (Phase B)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:13 +02:00
parteien = [ partei_lookup ] ,
2026-03-28 22:30:24 +01:00
typ = " wahlprogramm " ,
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
bundesland = bundesland ,
2026-03-28 22:30:24 +01:00
top_k = top_k_per_partei ,
min_similarity = 0.45 ,
)
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
# Parteiprogramm (Grundsatz, federal — bundesland=NULL matched implizit)
2026-03-28 22:30:24 +01:00
partei_chunks = find_relevant_chunks (
antrag_text ,
Phase B: Parteinamen-Mapper #55 (Roadmap #59)
Zentrale `app/parteien.py` als Single Source of Truth für die Partei-
Auflösung:
- `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen
bekannten Aliasen, optionalem `bundesland_scope` und Government-
Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW,
BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der
generische FREIE WÄHLER-Eintrag).
- `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups
mit Government-Vorrang und FW-Familien-Disambiguierung
- `extract_fraktionen(text, *, bundesland=None)` als Funnel für die
vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer-
partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope-
Filter (SSW nur in SH, BVB-FW nur in BB, etc.).
- `display_name(canonical, *, long=False)` für UI/PDF — kurze Form
bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN"
statt "GRÜNE" etc.
Adapter-Migration in `app/parlamente.py`:
- Vier nahezu identische `_normalize_fraktion()`-Methoden in
PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter
durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit
`self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt.
- `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen
für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`,
also keine Call-Site-Änderung nötig.
`app/embeddings.py:496` Workaround-Hack entfernt:
- `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen
`normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen
dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und
Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise
überall garantiert kanonisch.
Tests:
- Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW-
Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion,
Government-Marker, Tabellen-Konsistenz
- `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6
statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4
Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter
BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation
157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die
kanonischen Keys sind exakt die in der DB stehenden Strings, kein
Migrations-Schritt nötig.
Refs: #55, #59 (Phase B)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:13 +02:00
parteien = [ partei_lookup ] ,
2026-03-28 22:30:24 +01:00
typ = " parteiprogramm " ,
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
bundesland = bundesland ,
2026-03-28 22:30:24 +01:00
top_k = top_k_per_partei ,
min_similarity = 0.45 ,
)
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
2026-03-28 22:30:24 +01:00
if wahl_chunks or partei_chunks :
2026-04-09 21:57:56 +02:00
results [ partei_lookup ] = {
2026-03-28 22:30:24 +01:00
" wahlprogramm " : wahl_chunks ,
" parteiprogramm " : partei_chunks ,
}
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
2026-03-28 22:30:24 +01:00
return results
2026-04-08 11:24:31 +02:00
def _chunk_source_label ( chunk : dict ) - > str :
""" Build a fully-qualified source label like ' FDP MV Wahlprogramm 2021, S. 73 ' .
Without the programme name + Bundesland in the prompt , the LLM
halluzinates familiar sources from its training ( typically NRW 2022 )
even when the retrieved chunks all come from a different state .
"""
prog_id = chunk . get ( " programm_id " , " " )
info = PROGRAMME . get ( prog_id , { } )
name = info . get ( " name " ) or prog_id
seite = chunk . get ( " seite " , " ? " )
return f " { name } , S. { seite } "
#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
def _chunk_pdf_url ( chunk : dict ) - > Optional [ str ] :
""" Build the canonical PDF URL with page anchor for a chunk. """
prog_id = chunk . get ( " programm_id " , " " )
info = PROGRAMME . get ( prog_id )
if not info :
return None
pdf = info . get ( " pdf " )
if not pdf :
return None
seite = chunk . get ( " seite " )
if seite :
return f " /static/referenzen/ { pdf } #page= { seite } "
return f " /static/referenzen/ { pdf } "
# ─────────────────────────────────────────────────────────────────────────────
# Citation post-processing — Issue #60 Option B
#
# Pre-#60 the LLM was free to fabricate `quelle`/`url` strings even when the
# `text` was a real snippet from a retrieved chunk. The A+C fix made the
# prompt more strict, but BB 8/673 (post-deploy) showed the LLM still
# cross-mixed: it copied text from chunk Qn but wrote the page from chunk Qm
# in the `quelle` field.
#
# The structural fix is to take quelle/url generation away from the LLM
# entirely. After the LLM responds, we walk over every Zitat and try to
# locate its `text` (substring or 5-word anchor) in any of the chunks the
# LLM was actually shown. If we find a match, we *overwrite* quelle and url
# with the canonical values from that chunk. If we don't find a match, the
# Zitat is dropped — it cannot be backed by retrieved evidence.
# ─────────────────────────────────────────────────────────────────────────────
_RE_WHITESPACE = re . compile ( r " \ s+ " )
_RE_HYPHEN_BREAK = re . compile ( r " ( \ w)- \ s+( \ w) " )
_RE_TRUNCATION = re . compile ( r " ^ \ s* \ . { 2,}| \ . { 2,} \ s*$ " )
def _normalize_for_match ( text : str ) - > str :
""" Lowercase, collapse whitespace, bridge soft-hyphen line breaks.
Mirrors the matcher used in tests / integration / test_citations_substring . py
so that the analyzer ' s post-processing and Sub-D ' s verification stay in
lockstep .
"""
s = ( text or " " ) . lower ( )
s = _RE_TRUNCATION . sub ( " " , s )
s = s . replace ( " \u00ad " , " " ) # soft hyphen
s = _RE_WHITESPACE . sub ( " " , s ) . strip ( )
prev = None
while prev != s :
prev = s
s = _RE_HYPHEN_BREAK . sub ( r " \ 1 \ 2 " , s )
return s
def find_chunk_for_text ( text : str , chunks : list [ dict ] ) - > Optional [ dict ] :
""" Locate the retrieved chunk that a Zitat snippet was copied from.
Two - stage match identical to Sub - D :
1. * * Strict substring * * — full needle as substring of any chunk .
2. * * 5 - word anchor * * — any 5 consecutive words of the needle as
substring of any chunk .
Snippets shorter than 20 characters are rejected ( too weak to bind ) .
Returns the matching chunk dict , or None .
"""
needle = _normalize_for_match ( text )
if len ( needle ) < 20 :
return None
chunks_norm = [ ( c , _normalize_for_match ( c . get ( " text " , " " ) ) ) for c in chunks ]
for c , norm in chunks_norm :
if needle in norm :
return c
words = needle . split ( )
if len ( words ) < 5 :
return None
for i in range ( len ( words ) - 4 ) :
anchor = " " . join ( words [ i : i + 5 ] )
for c , norm in chunks_norm :
if anchor in norm :
return c
return None
def reconstruct_zitate ( data : dict , semantic_quotes : dict ) - > dict :
""" Replace LLM-emitted quelle/url with canonical chunk values; drop unbacked.
Walks over ` ` data [ ' wahlprogrammScores ' ] [ i ] [ kind ] [ ' zitate ' ] ` ` ( the raw
LLM - output dict , not the Pydantic model ) . For each Zitat :
* Locate the chunk whose text contains the snippet ( or a 5 - word anchor
from it ) . Search across * * all * * retrieved chunks regardless of party ,
so cross - mixes between Q - IDs become invisible to the persisted output .
* If found : overwrite ` ` quelle ` ` and ` ` url ` ` with values derived from
the matching chunk ' s ``programm_id`` + ``seite``. The LLM is no longer
trusted for these fields .
* If not found : drop the Zitat entirely .
Returns the same ` ` data ` ` dict ( mutated in place ) for chaining .
"""
if not semantic_quotes :
return data
all_chunks : list [ dict ] = [ ]
for d in semantic_quotes . values ( ) :
all_chunks . extend ( d . get ( " wahlprogramm " , [ ] ) )
all_chunks . extend ( d . get ( " parteiprogramm " , [ ] ) )
if not all_chunks :
return data
for fs in data . get ( " wahlprogrammScores " , [ ] ) or [ ] :
for kind in ( " wahlprogramm " , " parteiprogramm " ) :
blk = fs . get ( kind ) or { }
zitate = blk . get ( " zitate " ) or [ ]
cleaned = [ ]
for z in zitate :
text = z . get ( " text " , " " )
matched = find_chunk_for_text ( text , all_chunks )
if matched is None :
continue
z [ " quelle " ] = _chunk_source_label ( matched )
url = _chunk_pdf_url ( matched )
if url :
z [ " url " ] = url
cleaned . append ( z )
blk [ " zitate " ] = cleaned
return data
2026-03-28 22:30:24 +01:00
def format_quotes_for_prompt ( quotes : dict ) - > str :
2026-04-08 11:24:31 +02:00
""" Format quotes for inclusion in LLM prompt.
#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
Each chunk gets a stable ENUM - ID ( [ Q1 ] , [ Q2 ] , … ) and the prompt
instructs the LLM to anchor every citation in one of those IDs and
to copy the snippet * * verbatim * * from the cited chunk . This is the
structural fix for Issue #60: pre-#60 the LLM was free to invent
snippets under real source labels because nothing in the prompt
bound a citation to a specific retrieved chunk .
2026-04-08 11:24:31 +02:00
Each quote is annotated with the fully - qualified source ( programme
name + page ) so the LLM cannot fall back on training - set defaults
when constructing its citations .
"""
2026-03-28 22:30:24 +01:00
if not quotes :
return " "
2026-04-08 11:24:31 +02:00
2026-03-28 22:30:24 +01:00
lines = [ " \n ## Relevante Passagen aus Wahl- und Parteiprogrammen \n " ]
2026-04-08 11:24:31 +02:00
lines . append (
#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
" **ZITATEREGEL** — verbindlich für alle Zitate in `wahlprogramm`/ "
" `parteiprogramm`-Blöcken: \n "
" 1. Jedes Zitat MUSS auf genau einen der unten aufgelisteten "
" Chunks verweisen (Format `[Q1]`, `[Q2]`, …). \n "
" 2. Der `text`-String MUSS eine **wörtliche, zusammenhängende** "
" Passage von mindestens 5 Wörtern aus genau diesem Chunk sein — "
" keine Paraphrasen, keine Zusammenfassungen, keine "
" Cross-References aus dem Gedächtnis. \n "
" 3. Der `quelle`-String MUSS exakt das Source-Label des "
" gewählten Chunks sein (Programm-Name + Seitenzahl, wie unten "
" ausgeschrieben). \n "
" 4. Wenn kein Chunk wirklich passt: lass das Zitat-Array leer. "
" Lieber 0 Zitate als ein erfundenes Zitat. \n "
2026-04-08 11:24:31 +02:00
)
#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
counter = 0
2026-03-28 22:30:24 +01:00
for partei , data in quotes . items ( ) :
lines . append ( f " \n ### { partei } \n " )
2026-04-08 11:24:31 +02:00
2026-03-28 22:30:24 +01:00
if data . get ( " wahlprogramm " ) :
Refactor wahlprogramme/embeddings/analyzer for multi-state (#5)
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:48:11 +02:00
lines . append ( " **Wahlprogramm:** " )
2026-03-28 22:30:24 +01:00
for chunk in data [ " wahlprogramm " ] :
#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
counter + = 1
2026-03-28 22:30:24 +01:00
text = chunk [ " text " ] [ : 500 ] + " ... " if len ( chunk [ " text " ] ) > 500 else chunk [ " text " ]
#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
lines . append ( f ' - [Q { counter } ] { _chunk_source_label ( chunk ) } : " { text } " ' )
2026-04-08 11:24:31 +02:00
2026-03-28 22:30:24 +01:00
if data . get ( " parteiprogramm " ) :
lines . append ( " \n **Grundsatzprogramm:** " )
for chunk in data [ " parteiprogramm " ] :
#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
counter + = 1
2026-03-28 22:30:24 +01:00
text = chunk [ " text " ] [ : 500 ] + " ... " if len ( chunk [ " text " ] ) > 500 else chunk [ " text " ]
#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
lines . append ( f ' - [Q { counter } ] { _chunk_source_label ( chunk ) } : " { text } " ' )
2026-04-08 11:24:31 +02:00
2026-03-28 22:30:24 +01:00
return " \n " . join ( lines )
def get_programme_info ( ) - > list [ dict ] :
""" Get list of all indexed programmes with metadata. """
info_list = [ ]
for prog_id , info in PROGRAMME . items ( ) :
info_list . append ( {
" id " : prog_id ,
" name " : info [ " name " ] ,
" typ " : info [ " typ " ] ,
" partei " : info [ " partei " ] ,
" bundesland " : info . get ( " bundesland " ) ,
" pdf " : info [ " pdf " ] ,
" pdf_url " : f " /static/referenzen/ { info [ ' pdf ' ] } " ,
} )
return info_list
def get_indexing_status ( ) - > dict :
""" Get status of indexed programmes. """
if not EMBEDDINGS_DB . exists ( ) :
return { " indexed " : 0 , " programmes " : [ ] }
conn = sqlite3 . connect ( EMBEDDINGS_DB )
# Count chunks per program
rows = conn . execute ( """
SELECT programm_id , COUNT ( * ) as chunks
FROM chunks
GROUP BY programm_id
""" ).fetchall()
conn . close ( )
indexed = { row [ 0 ] : row [ 1 ] for row in rows }
programmes = [ ]
for prog_id , info in PROGRAMME . items ( ) :
programmes . append ( {
" id " : prog_id ,
" name " : info [ " name " ] ,
" partei " : info [ " partei " ] ,
" chunks " : indexed . get ( prog_id , 0 ) ,
" indexed " : prog_id in indexed ,
} )
return {
" indexed " : len ( indexed ) ,
" total " : len ( PROGRAMME ) ,
" programmes " : programmes ,
}