feat(#6 Phase 11): Bundesrats-Drucksachen erkennen + markieren + ablehnen

DIP-Drucksachen mit `herausgeber: 'BR'` (Bundesrat) haben Bundesländer
als Antragsteller (z.B. SN, HE) statt Fraktionen. Variante b — explizite
Behandlung statt nur ausschließen:

- Drucksache-dataclass: neue Felder `is_bundesrat: bool`,
  `urheber_bundeslaender: list[str]`. Existierende Pfade unberührt.
- BundestagAdapter._doc_to_drucksache: liest herausgeber + urheber-Liste,
  setzt Bundesländer-Codes (bezeichnung wie "SN") in
  urheber_bundeslaender. fraktionen bleibt leer fuer BR — verhindert
  dass Stimmverhalten-Aggregate verwirrt werden.
- /api/search-landtag liefert is_bundesrat + urheber_bundeslaender im
  Response.
- /api/analyze-drucksache (POST) lehnt BR-Drucksachen mit HTTP 400 +
  klarer Meldung ab statt crashen.
- v2-Search-UI rendert grayen Bundesrat-Sticker mit BL-Codes statt
  Fraktionen, "Analysieren"-Button durch "nicht unterstuetzt" ersetzt.

is_bundesrat_drucksache() in drucksache_typen.py als Format-Helper
(N/M/JJ-Pattern) bleibt fuer Cases wo nur die Drucksache-ID ohne
Adapter-Metadaten verfuegbar ist.

Refs: #6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-06 23:29:12 +02:00
parent 03948038c4
commit 5667259bff
4 changed files with 84 additions and 10 deletions

View File

@ -128,3 +128,25 @@ def likely_kleine_anfrage_titel(title: str) -> bool:
if t.rstrip().endswith("?"): if t.rstrip().endswith("?"):
return True return True
return False return False
# Bundesrats-Drucksachen-Format erkennen.
# Bundestag: "21/9954" (Wahlperiode/Nummer, 2 Komponenten).
# Bundesrat: "186/3/26", "186/2/26" (Nummer/Sub/Jahr, 3 Komponenten).
import re as _re
_BUNDESRAT_PATTERN = _re.compile(r"^\d+/\d+/\d+$")
def is_bundesrat_drucksache(drucksache: str) -> bool:
"""Erkennt Bundesrats-Drucksachen am 3-Komponenten-Format ``N/M/JJ``.
Diese Drucksachen finden sich über die DIP-API (Bundestags-Backend
indexiert sie mit), haben aber **keine Fraktionen** als Antragsteller
sondern Bundesländer. Der GWÖ-Analyzer erwartet Fraktionen und schlägt
bei Bundesrats-Drucksachen fehl. Folge: separate Behandlung im Such-
+ Analyse-Pfad.
"""
if not drucksache:
return False
return bool(_BUNDESRAT_PATTERN.match(drucksache.strip()))

View File

@ -1416,6 +1416,8 @@ async def search_landtag(
"bundesland": bundesland, "bundesland": bundesland,
"typ": doc.typ, "typ": doc.typ,
"typ_normiert": doc.typ_normiert, "typ_normiert": doc.typ_normiert,
"is_bundesrat": doc.is_bundesrat,
"urheber_bundeslaender": doc.urheber_bundeslaender,
"gwoeScore": None, "gwoeScore": None,
"status": "unchecked", "status": "unchecked",
}) })
@ -1572,6 +1574,20 @@ async def analyze_drucksache(
# Get document metadata # Get document metadata
doc = await adapter.get_document(drucksache) doc = await adapter.get_document(drucksache)
# Bundesrats-Drucksachen ablehnen — Antragsteller sind Bundesländer,
# GWÖ-Wahlprogramm-Bewertung greift nicht (kein Partei-Bezug).
if doc and getattr(doc, "is_bundesrat", False):
bls = ", ".join(doc.urheber_bundeslaender or []) or ""
raise HTTPException(
status_code=400,
detail=(
f"Bundesrats-Drucksache (Antragsteller: {bls}) wird derzeit "
f"nicht unterstützt. Die GWÖ-Bewertung erwartet eine "
f"antragstellende Fraktion, Bundesländer-Anträge aus dem "
f"Bundesrat haben aber keine Wahlprogramm-Verbindung."
),
)
# #127: Typ-Check — nur abstimmbare Drucksachen analysieren. # #127: Typ-Check — nur abstimmbare Drucksachen analysieren.
# Falls der Adapter den Typ nicht richtig setzt (NRW: "Drucksache"), # Falls der Adapter den Typ nicht richtig setzt (NRW: "Drucksache"),
# versuche den Typ aus dem Dokument-Text zu erkennen. # versuche den Typ aus dem Dokument-Text zu erkennen.

View File

@ -23,11 +23,18 @@ class Drucksache:
bundesland: str bundesland: str
typ: str = "Antrag" # Original-Typ vom Landtag (z.B. "Kleine Anfrage", "Gesetzentwurf") typ: str = "Antrag" # Original-Typ vom Landtag (z.B. "Kleine Anfrage", "Gesetzentwurf")
typ_normiert: str = "" # Normierter Typ (wird automatisch gesetzt) typ_normiert: str = "" # Normierter Typ (wird automatisch gesetzt)
# Bundesrats-Drucksachen (DIP herausgeber=BR): Antragsteller sind
# Bundesländer (z.B. ['SN','HE']) statt Fraktionen. fraktionen bleibt
# leer, damit Stimmverhalten-Aggregate sich nicht verwirren.
is_bundesrat: bool = False
urheber_bundeslaender: list[str] = None # type: ignore[assignment]
def __post_init__(self): def __post_init__(self):
from .drucksache_typen import normalize_typ from .drucksache_typen import normalize_typ
if not self.typ_normiert: if not self.typ_normiert:
self.typ_normiert = normalize_typ(self.typ) self.typ_normiert = normalize_typ(self.typ)
if self.urheber_bundeslaender is None:
self.urheber_bundeslaender = []
class ParlamentAdapter(ABC): class ParlamentAdapter(ABC):
@ -2928,13 +2935,28 @@ class BundestagAdapter(ParlamentAdapter):
if not pdf_url: if not pdf_url:
return None return None
# Fraktionen aus urheber-Liste extrahieren. DIP listet sie als # Bundesrats-Drucksachen erkennen am DIP-Feld `herausgeber: 'BR'`.
# "Fraktion der AfD" o.ä. — extract_fraktionen kennt das Pattern # Antragsteller sind dort Bundesländer (z.B. Sachsen, Hessen),
# bereits aus den Landtags-Adaptern. # nicht Bundestags-Fraktionen.
is_bundesrat = (doc.get("herausgeber") or "").upper() == "BR"
urheber_strs: list[str] = [] urheber_strs: list[str] = []
urheber_bundeslaender: list[str] = []
for u in (doc.get("urheber") or []): for u in (doc.get("urheber") or []):
if isinstance(u, dict): if isinstance(u, dict):
urheber_strs.append(u.get("titel") or u.get("bezeichnung") or "") urheber_strs.append(u.get("titel") or u.get("bezeichnung") or "")
# Bei Bundesrat: bezeichnung ist der BL-Code (SN, HE, NW, ...)
if is_bundesrat:
code = (u.get("bezeichnung") or "").strip().upper()
if code and len(code) <= 4:
urheber_bundeslaender.append(code)
if is_bundesrat:
# Fraktionen leer lassen — Bundesländer-Antragsteller sind
# in urheber_bundeslaender. Stimmverhalten-Aggregate ignorieren
# diese Drucksachen automatisch (kein fraktionen-Match).
fraktionen: list[str] = []
else:
urheber_combined = ", ".join(filter(None, urheber_strs)) urheber_combined = ", ".join(filter(None, urheber_strs))
fraktionen = extract_fraktionen(urheber_combined, bundesland=self.bundesland) fraktionen = extract_fraktionen(urheber_combined, bundesland=self.bundesland)
@ -2946,6 +2968,8 @@ class BundestagAdapter(ParlamentAdapter):
link=pdf_url, link=pdf_url,
bundesland=self.bundesland, bundesland=self.bundesland,
typ=doc.get("drucksachetyp", "Antrag"), typ=doc.get("drucksachetyp", "Antrag"),
is_bundesrat=is_bundesrat,
urheber_bundeslaender=urheber_bundeslaender,
) )
async def _fetch_page( async def _fetch_page(

View File

@ -273,29 +273,41 @@ function renderRow(item, bl) {
var url = item.url || item.link || ''; var url = item.url || item.link || '';
var done = lsCheckedIds.has(ds); var done = lsCheckedIds.has(ds);
var fraktionen = Array.isArray(item.fraktionen) ? item.fraktionen : []; var fraktionen = Array.isArray(item.fraktionen) ? item.fraktionen : [];
var isBundesrat = !!item.is_bundesrat;
var bundeslaender = Array.isArray(item.urheber_bundeslaender) ? item.urheber_bundeslaender : [];
var titleHtml = url var titleHtml = url
? '<a href="' + escHtml(url) + '" target="_blank" rel="noopener">' + title + '</a>' ? '<a href="' + escHtml(url) + '" target="_blank" rel="noopener">' + title + '</a>'
: title; : title;
var fraktionenHtml = fraktionen.length var fraktionenHtml = '';
? '<div class="ls-fraktionen">' if (isBundesrat) {
var blsList = bundeslaender.length
? bundeslaender.map(function(c){ return escHtml(c); }).join(', ')
: '—';
fraktionenHtml = '<div class="ls-fraktionen" style="opacity:0.85;">'
+ '<span class="ls-fraktion" style="background:rgba(247,148,29,0.18);color:#bf6c10;border-color:rgba(247,148,29,0.4);" title="Bundesrats-Drucksache: Antragsteller sind Bundesländer, nicht Fraktionen. Wird derzeit nicht für die GWÖ-Bewertung unterstützt.">Bundesrat · ' + blsList + '</span>'
+ '</div>';
} else if (fraktionen.length) {
fraktionenHtml = '<div class="ls-fraktionen">'
+ fraktionen.map(function (f) { + fraktionen.map(function (f) {
return '<span class="ls-fraktion">' + escHtml(f) + '</span>'; return '<span class="ls-fraktion">' + escHtml(f) + '</span>';
}).join('') }).join('')
+ '</div>' + '</div>';
: ''; }
var actionHtml; var actionHtml;
if (done) { if (done) {
actionHtml = '<span class="ls-badge-done">Bewertet → <a href="/antrag/' + encodeURIComponent(ds) + '" style="color:inherit;">Ansehen</a></span>'; actionHtml = '<span class="ls-badge-done">Bewertet → <a href="/antrag/' + encodeURIComponent(ds) + '" style="color:inherit;">Ansehen</a></span>';
} else if (isBundesrat) {
actionHtml = '<span style="font-family:var(--font-mono);font-size:10px;opacity:0.55;" title="Bundesrats-Drucksachen werden derzeit nicht analysiert.">nicht unterstützt</span>';
} else if (lsIsAuth) { } else if (lsIsAuth) {
actionHtml = '<button class="ls-btn-analyse" onclick="lsAnalyse(this,\'' + escAttr(ds) + '\',\'' + escAttr(bl) + '\')">Analysieren</button>'; actionHtml = '<button class="ls-btn-analyse" onclick="lsAnalyse(this,\'' + escAttr(ds) + '\',\'' + escAttr(bl) + '\')">Analysieren</button>';
} else { } else {
actionHtml = '<span style="font-family:var(--font-mono);font-size:10px;opacity:0.5;">Anmeldung nötig</span>'; actionHtml = '<span style="font-family:var(--font-mono);font-size:10px;opacity:0.5;">Anmeldung nötig</span>';
} }
return '<div class="ls-row">' return '<div class="ls-row"' + (isBundesrat ? ' style="opacity:0.7;"' : '') + '>'
+ '<div class="ls-drucksache">' + escHtml(ds) + '</div>' + '<div class="ls-drucksache">' + escHtml(ds) + '</div>'
+ '<div class="ls-main">' + '<div class="ls-main">'
+ '<div class="ls-title">' + titleHtml + '</div>' + '<div class="ls-title">' + titleHtml + '</div>'